Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f99d4190c | ||
|
|
5f990573c1 | ||
|
|
b50b5af763 | ||
|
|
35c9e4c34e | ||
|
|
7b6540a748 | ||
|
|
4b290fd0b7 | ||
|
|
f9d7cf2453 | ||
|
|
ac8aeebfbe | ||
|
|
1675b07694 | ||
|
|
d4f41892cd | ||
|
|
5046c21045 | ||
|
|
9c50e1a7b4 | ||
|
|
33df5632a3 | ||
|
|
3b39d847cd | ||
|
|
dff25c62d9 | ||
|
|
200dfb1dbf | ||
|
|
a99cde8839 | ||
|
|
0e8b6f813d | ||
|
|
7b6872c4b7 | ||
|
|
c17f7065d9 | ||
|
|
f44d987bc9 | ||
|
|
dc2ce02d1a | ||
|
|
b9d692cf5b | ||
|
|
c19cf531b8 | ||
|
|
f0ef34646e | ||
|
|
6a340e9554 | ||
|
|
38565bbce2 | ||
|
|
c32b54f588 | ||
|
|
44987d2b5e | ||
|
|
2c3e8970cb | ||
|
|
395af0080c | ||
|
|
e3ea5fb6dc | ||
|
|
4451e921ca | ||
|
|
92949b7596 | ||
|
|
57bfbf874b | ||
|
|
1dc423a1c4 | ||
|
|
de8aaa5c45 | ||
|
|
1a1e3204ee | ||
|
|
6c4cc295b8 | ||
|
|
9f212ddcb8 | ||
|
|
5f82278282 | ||
|
|
f6a32ce610 | ||
|
|
0839975083 | ||
|
|
c90f3d0f01 | ||
|
|
5f3daf689b | ||
|
|
cc7fcdb4ef | ||
|
|
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 | ||
|
|
cf6ad0b8e0 | ||
|
|
782dc5754d | ||
|
|
cf88680a13 | ||
|
|
6293259225 | ||
|
|
a8678b8842 | ||
|
|
5993b842a0 | ||
|
|
78cef418a1 | ||
|
|
d56ba0cf9b | ||
|
|
72dffe12e5 | ||
|
|
2d353ef839 | ||
|
|
f26ef53c8c | ||
|
|
98b95b2ab2 | ||
|
|
bc8611a662 | ||
|
|
2ddfceca36 | ||
|
|
862babb8f5 | ||
|
|
61cea95697 | ||
|
|
1fc1e23fce | ||
|
|
76155232a3 | ||
|
|
fa9e9de032 | ||
|
|
a6bd2e7dba | ||
|
|
e58dc1d056 | ||
|
|
ddc14ae853 | ||
|
|
0d54b1a631 | ||
|
|
afc405ec02 | ||
|
|
6a9f6aa65a | ||
|
|
8f725c7c84 | ||
|
|
97ab0a2c2c | ||
|
|
ddd58c20aa | ||
|
|
489c465599 | ||
|
|
451dd0327f | ||
|
|
e1f826357d | ||
|
|
8a136dde55 | ||
|
|
cc9b0f8a73 | ||
|
|
3cada63f15 | ||
|
|
7fdcb22515 | ||
|
|
d7b5d3f60e | ||
|
|
24f82b1be5 | ||
|
|
708dc81b88 | ||
|
|
35f5275d88 | ||
|
|
9567144390 | ||
|
|
1a5607f278 | ||
|
|
7e25c148cc | ||
|
|
122713c39a | ||
|
|
070fd812b2 | ||
|
|
4156cd436d | ||
|
|
996852a506 | ||
|
|
2fb588357a | ||
|
|
c3a276cde3 | ||
|
|
661bf2a9e7 | ||
|
|
007a908e83 | ||
|
|
23b4ec992d | ||
|
|
726f66762b | ||
|
|
3b77ca5570 | ||
|
|
8c0304273c | ||
|
|
dc9b88dedb | ||
|
|
409d1b83eb | ||
|
|
c3bd5d1d5f | ||
|
|
3336fa1e38 | ||
|
|
769e3748a0 | ||
|
|
91eb0f3a69 | ||
|
|
863e784e1c | ||
|
|
780741e37f | ||
|
|
e50c33ae4b | ||
|
|
f34052ed73 | ||
|
|
a555a12736 | ||
|
|
f37bfccbc2 | ||
|
|
db1496e525 | ||
|
|
443c6271f1 | ||
|
|
9cf5222c8c | ||
|
|
2485d2ff1d | ||
|
|
cb40669385 | ||
|
|
d9e7f9f5cf | ||
|
|
dafa90f0dd | ||
|
|
9737c2b67d | ||
|
|
1cd89d1375 | ||
|
|
11ef47b576 | ||
|
|
fbd303801f | ||
|
|
0ecd82d62e | ||
|
|
1452cbd591 | ||
|
|
07f875c18c | ||
|
|
5e4adb71ad | ||
|
|
1019ba0d82 | ||
|
|
6b25bbe2f8 | ||
|
|
b9758149f5 | ||
|
|
d438abf1a5 | ||
|
|
9375c598ee | ||
|
|
8f636cc5b9 | ||
|
|
1430f31e2d | ||
|
|
3225c5005b | ||
|
|
f36b213f0a | ||
|
|
cb15fad7da | ||
|
|
a3bbb1ed64 | ||
|
|
04275e3cf5 | ||
|
|
badb035585 | ||
|
|
c12d0c0898 | ||
|
|
6e9f11f6a7 | ||
|
|
1713b2398d | ||
|
|
68f99798f8 | ||
|
|
5a0a41c6bb | ||
|
|
129a496206 | ||
|
|
d0191be31d | ||
|
|
85b9d7afe7 | ||
|
|
ff5c79cfcf | ||
|
|
672f1e6657 | ||
|
|
433e796c04 | ||
|
|
77089fcdd6 | ||
|
|
a372e55b78 | ||
|
|
8ec0f50f68 | ||
|
|
dbf58046e7 | ||
|
|
d238965c2c | ||
|
|
53ecda9f7a | ||
|
|
fc535a558f | ||
|
|
f7ac25f91f | ||
|
|
9d8b4a59fb | ||
|
|
60804c12b0 | ||
|
|
90f8170efb |
@@ -57,6 +57,7 @@ vitest.config.ts
|
||||
Dockerfile*
|
||||
docker
|
||||
!docker/nginx
|
||||
!docker/*.sh
|
||||
.dockerignore
|
||||
|
||||
# Misc
|
||||
|
||||
@@ -18,6 +18,20 @@ DASHBOARD_DOMAIN=app.example.com
|
||||
LANDING_DOMAIN=www.example.com
|
||||
WIKI_DOMAIN=docs.example.com
|
||||
|
||||
# For local development: URIs for running services locally
|
||||
# These are used by the applications at runtime
|
||||
API_URI=http://localhost:8080
|
||||
DASHBOARD_URI=http://localhost:3000
|
||||
LANDING_URI=http://localhost:4000
|
||||
WIKI_URI=http://localhost:1000
|
||||
|
||||
# NEXT_PUBLIC_* variables are used for client-side code and sitemap generation
|
||||
# Use placeholder URLs that will be replaced at Docker container runtime
|
||||
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
|
||||
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
|
||||
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com
|
||||
NEXT_PUBLIC_WIKI_URI=https://next-wiki.useplunk.com
|
||||
|
||||
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
|
||||
# This affects how application URIs are auto-generated from domain names
|
||||
USE_HTTPS=false
|
||||
@@ -112,6 +126,40 @@ SMTP_DOMAIN=smtp.example.com
|
||||
# Maximum recipients per email (default: 5)
|
||||
# MAX_RECIPIENTS=5
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: Platform emails
|
||||
# ========================================
|
||||
# Your Plunk instance will send emails if you provide a Plunk API key and a from address
|
||||
# These emails include system notifications, for example when your project hits billing limits
|
||||
|
||||
# PLUNK_API_KEY=
|
||||
# PLUNK_FROM_ADDRESS=
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: Security Settings
|
||||
# ========================================
|
||||
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
|
||||
# When enabled (default), projects exceeding security limits will be automatically suspended
|
||||
# When disabled, violations will be logged and notifications sent, but projects won't be auto-disabled
|
||||
# Recommended for self-hosters: false (manage project status manually)
|
||||
# Default: true
|
||||
# AUTO_PROJECT_DISABLE=false
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: Self-Hosting User Management
|
||||
# ========================================
|
||||
# Controls whether new user signups are allowed
|
||||
# When enabled (true), the signup endpoint will reject new user registration attempts
|
||||
# Useful for private instances or when you want to manually manage users
|
||||
# Default: false
|
||||
# DISABLE_SIGNUPS=false
|
||||
|
||||
# Controls whether email validation checks are performed on signup
|
||||
# When enabled (true), validates emails for disposable domains, plus-addressing, domain existence, and MX records
|
||||
# When disabled (false), skips these validation checks and allows any email format
|
||||
# Default: false
|
||||
# VERIFY_EMAIL_ON_SIGNUP=false
|
||||
|
||||
# ========================================
|
||||
# ADVANCED (rarely needed)
|
||||
# ========================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
IMAGE="ghcr.io/${{ github.repository }}"
|
||||
|
||||
if [[ "${{ steps.check-release.outputs.is_release }}" == "true" ]]; then
|
||||
# This is a release: use semver tags
|
||||
# This is a release: use semver tags + latest
|
||||
TAG="${{ steps.check-release.outputs.release_tag }}"
|
||||
VERSION="${TAG#v}"
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
@@ -70,9 +70,9 @@ jobs:
|
||||
TAGS="${VERSION},${MAJOR}.${MINOR},${MAJOR},latest"
|
||||
echo "Building RELEASE with tags: $TAGS"
|
||||
else
|
||||
# Regular commit: use SHA tags
|
||||
# Regular commit: use SHA tags only (no latest)
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
TAGS="sha-${SHORT_SHA},latest"
|
||||
TAGS="sha-${SHORT_SHA}"
|
||||
echo "Building COMMIT with tags: $TAGS"
|
||||
fi
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
# Merge platform-specific images into multi-arch manifest
|
||||
merge:
|
||||
needs: [prepare, build]
|
||||
needs: [ prepare, build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.1.0"
|
||||
".": "0.8.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" ]
|
||||
|
||||
|
||||
+263
@@ -1,5 +1,268 @@
|
||||
# Changelog
|
||||
|
||||
## [0.8.0](https://github.com/useplunk/plunk/compare/v0.7.1...v0.8.0) (2026-03-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add multi-branch workflow conditions (switch/case) ([92949b7](https://github.com/useplunk/plunk/commit/92949b7596740b73d04c30e0e2bcc6305bbdf4a7))
|
||||
* **i18n:** add Chinese translations (zh-TW, zh-HK, zh-CN) ([33df563](https://github.com/useplunk/plunk/commit/33df5632a3d4068e641687c8d404a6eef4aa4d0e))
|
||||
* **i18n:** add Italian translation ([7b6872c](https://github.com/useplunk/plunk/commit/7b6872c4b79b0fde4491f02851a2049c846c83d6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adapt SNS Webhook validation regex pattern to also support AWS eusc partition ([7b6540a](https://github.com/useplunk/plunk/commit/7b6540a748a90c10f41b931450ac809e7ae23c01))
|
||||
* add alt text to email badge image ([3b39d84](https://github.com/useplunk/plunk/commit/3b39d847cd278317723b4b226434c26c49becebe))
|
||||
* broken links to next-wiki.useplunk.com ([e3ea5fb](https://github.com/useplunk/plunk/commit/e3ea5fb6dcea95b5028f140b77e54d5938497223))
|
||||
* Connect branches smoothly to original node ([2c3e897](https://github.com/useplunk/plunk/commit/2c3e8970cbc4ad2eb39fd505a4292345c6cae1c0))
|
||||
* Enhance email step configuration validation and recipient handling ([b50b5af](https://github.com/useplunk/plunk/commit/b50b5af763d8a44b09c7022e1d9135bfca7907f6))
|
||||
* Enhance email step configuration validation and recipient handling ([a99cde8](https://github.com/useplunk/plunk/commit/a99cde88392450bf719112cb94cc1b9e7c7c1478))
|
||||
* normalize field references and handle undefined values in JSON ([b9d692c](https://github.com/useplunk/plunk/commit/b9d692cf5b491ab22a40ec69e77ff7bdc62eec63))
|
||||
* Prevent switching if nodes are attached to multi-branch ([c32b54f](https://github.com/useplunk/plunk/commit/c32b54f588db79a501db456e6075a337dee8ed26))
|
||||
* update old references to next.useplunk.com ([44987d2](https://github.com/useplunk/plunk/commit/44987d2b5e8028c6f8f26c39e9dec386e1ccf6e1))
|
||||
* visual editor preview ([4451e92](https://github.com/useplunk/plunk/commit/4451e921caff45dab126006ba103d91816bed696))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Fix broken links on api overview page ([dc2ce02](https://github.com/useplunk/plunk/commit/dc2ce02d1a4e4d1641728b720ca856cedba719a7))
|
||||
* Improve self-hosting env variable documentation ([f0ef346](https://github.com/useplunk/plunk/commit/f0ef34646e7593f07a780141e14d6d2b735c1101))
|
||||
|
||||
## [0.7.1](https://github.com/useplunk/plunk/compare/v0.7.0...v0.7.1) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Align preview and actual email for templates and campaigns ([cc7fcdb](https://github.com/useplunk/plunk/commit/cc7fcdb4ef31e9dfb4f7403aa93479b6df56719d))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add inbound to docs ([6c4cc29](https://github.com/useplunk/plunk/commit/6c4cc295b88db6da8d9edcf23064a3fc8e9f5c36))
|
||||
* Improve webhook documentation ([f6a32ce](https://github.com/useplunk/plunk/commit/f6a32ce610559050c1ca9978607bd57ac51e906f))
|
||||
|
||||
## [0.7.0](https://github.com/useplunk/plunk/compare/v0.6.0...v0.7.0) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** support inline images in emails using Content-ID ([9b15b93](https://github.com/useplunk/plunk/commit/9b15b93b96344f811d869d103b3b6d344b531811))
|
||||
* Sort projects alphabetically in the dashboard and fix layout ([64bd094](https://github.com/useplunk/plunk/commit/64bd094b47abbc4feaeb93d97915df57763b3907))
|
||||
* Static segments ([4b51e38](https://github.com/useplunk/plunk/commit/4b51e386e39ae38d6ea52eb87858de36fa45ab46))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add support for STATIC segment type in CampaignService ([7bd098b](https://github.com/useplunk/plunk/commit/7bd098bdd01a8af0dd41ec515880289b1795e5af))
|
||||
* correct cookie domain for .local TLD hostnames ([59aa784](https://github.com/useplunk/plunk/commit/59aa7845bad69a1768bb88f83cfcea607c447538))
|
||||
* Correctly set domain status on manual verify ([21af8fe](https://github.com/useplunk/plunk/commit/21af8fe05e0450e8d826a3a01224511a337ca072))
|
||||
* Do not unsubscribe existing contacts ([2c4d95e](https://github.com/useplunk/plunk/commit/2c4d95e604cbed9189f7ee07c24b1f236fce7990))
|
||||
* Support any locale on creation ([ec1f4c9](https://github.com/useplunk/plunk/commit/ec1f4c9374e06e3defed3c728be603c10e4e7baa))
|
||||
* Verify SNS URL before sending fetch request ([b8f1ad9](https://github.com/useplunk/plunk/commit/b8f1ad9ab53c78f8ef063fdc125f397c8bfc7652))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Static segments ([e8a247f](https://github.com/useplunk/plunk/commit/e8a247fe12b79ae8a25a74485179e93081fe2002))
|
||||
|
||||
## [0.6.0](https://github.com/useplunk/plunk/compare/v0.5.0...v0.6.0) (2026-02-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Ability to change workflow trigger ([eec37ec](https://github.com/useplunk/plunk/commit/eec37ecb31c63888879a1933dba4fd0c6243018b))
|
||||
* Add billing for inbound ([3330d83](https://github.com/useplunk/plunk/commit/3330d83d08904bc547740bfc9fdcbc5ff214d977))
|
||||
* Add billing for inbound ([de7ed84](https://github.com/useplunk/plunk/commit/de7ed84fcfddde16a9297c947048e9876712f6fa))
|
||||
* Add dedicated received type ([f947ee6](https://github.com/useplunk/plunk/commit/f947ee6f22371055801fa9cfc62e15df7851986c))
|
||||
* **i18n:** Add Spanish language ([9c69c7a](https://github.com/useplunk/plunk/commit/9c69c7a36edd86fdc02b8d0d3e3f8d64ed8ecfa6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Enhance email bounce notification with latest bounce details ([e639c72](https://github.com/useplunk/plunk/commit/e639c72e8b940ba1da472d355f930359da7c868b))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add receiving emails functionality and update DNS records documentation ([b426986](https://github.com/useplunk/plunk/commit/b42698616a3f7bb079a092375ac886f2a9d7405d))
|
||||
|
||||
## [0.5.0](https://github.com/useplunk/plunk/compare/v0.4.0...v0.5.0) (2026-02-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Ability to disable signups and disable email verification for self-hosters ([9316564](https://github.com/useplunk/plunk/commit/93165644af1ebcd7d5eb1900b13e5e38c1af0262))
|
||||
* add "olderThan" segment filter operator ([b5fa21a](https://github.com/useplunk/plunk/commit/b5fa21a57cbf491e0438eb29fc9419063fa2aea7))
|
||||
* Add additional checks for website, NS records and personal emails ([0a67a82](https://github.com/useplunk/plunk/commit/0a67a8278f45d89b1abdf55be1cf251a470595cf))
|
||||
* Add advanced DNS configuration ([7964563](https://github.com/useplunk/plunk/commit/7964563b620868279f5fb6baab0d7499c41f51ed))
|
||||
* Add bounce and complaint filter to activity feed ([c85df75](https://github.com/useplunk/plunk/commit/c85df75d539f7e5500a9a109966b0e6d654d4022))
|
||||
* add documentation link and redirect to WIKI_URI ([4c81d9e](https://github.com/useplunk/plunk/commit/4c81d9ec04003550b140d884f37ebbf13137166d))
|
||||
* Add inbound handling ([d050b55](https://github.com/useplunk/plunk/commit/d050b55baaf46306be3fb6e5ad683205e158bd65))
|
||||
* Add initial handling in webhook for inbound ([e01dc40](https://github.com/useplunk/plunk/commit/e01dc4066c5e5eb5e0e69ef92f8a5bb7786e5a91))
|
||||
* Add initial handling in webhook for inbound ([c286f49](https://github.com/useplunk/plunk/commit/c286f490974d8dfe9c94695c63e6b48216b8052d))
|
||||
* add minimum thresholds for bounce and complaint rates ([d55dda3](https://github.com/useplunk/plunk/commit/d55dda312718890231d1a428448e607792799c84))
|
||||
* Add support for custom email recipients in workflow steps ([78d3d22](https://github.com/useplunk/plunk/commit/78d3d224af60cf5b58c6b18fdd0921320912fb09))
|
||||
* **i18n:** Add Bulgarian translations ([663bc2b](https://github.com/useplunk/plunk/commit/663bc2be8da69733d2bbc53c6b1bfcc36ff55cf8))
|
||||
* **i18n:** add Czech locale translations ([14c471e](https://github.com/useplunk/plunk/commit/14c471e4cadbe1bbf90e53522134fad3d81ff107))
|
||||
* **i18n:** add Polish locale translations ([7a6df77](https://github.com/useplunk/plunk/commit/7a6df7760bc73dfe80dcf2d37612e320f6de1d30))
|
||||
* **i18n:** add Polish locale translations ([d2d779c](https://github.com/useplunk/plunk/commit/d2d779c68f958a6ac0e77b7588e92a5c705a8fd4)), closes [#246](https://github.com/useplunk/plunk/issues/246)
|
||||
* **i18n:** Add Portuguese translations ([c2afb2d](https://github.com/useplunk/plunk/commit/c2afb2dfab977ae74f3b694601a1f2d0353660a8))
|
||||
* Integrate NuqsAdapter for improved state management and query handling ([0cf0a26](https://github.com/useplunk/plunk/commit/0cf0a26a97e608f654ed3046c1a46d414557e3ee))
|
||||
* Remove domain from AWS if no longer in use by other projects ([e6baace](https://github.com/useplunk/plunk/commit/e6baace00d5f7a109e717b8a996bc2cda52cc9fa))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* added missing services:down script, added "win32" to supportedArchitectures for yarn package installation, added missing required WIKI_URI to .env.example of api ([ddbe523](https://github.com/useplunk/plunk/commit/ddbe5237fe7012e197e3b398aebc8e2921a4328a))
|
||||
* Center "Add Step" nodes below parent nodes and update positions on drag ([e4e334c](https://github.com/useplunk/plunk/commit/e4e334c77a7ba41e5a60b69b9c64fa3bb72f1e74))
|
||||
* Enhance email activity filtering by adding date range checks ([ad0c17d](https://github.com/useplunk/plunk/commit/ad0c17da566a26a296229c05d9b4e18cf7d401ad))
|
||||
* Ensure template type is loaded before rendering Select ([8a38766](https://github.com/useplunk/plunk/commit/8a38766c025afd5ef15fee4b7c5baea97a45ec48))
|
||||
* Implement merging for activity updates to preserve component state ([0d4b694](https://github.com/useplunk/plunk/commit/0d4b694208fd8e516e1f50f7384d1ac5bd6561e0))
|
||||
* Improve bounce handling logic to differentiate between permanent and transient bounces ([7df43d8](https://github.com/useplunk/plunk/commit/7df43d8553eca93b601915ea4deaf59233e848c7))
|
||||
* Improve email verification logic by prioritizing MX record checks and clarifying domain existence validation ([26800a8](https://github.com/useplunk/plunk/commit/26800a85538fc90aa05fc43b1cc33cef95d8fb32))
|
||||
* Refactor Redis keys to prevent multiple messages on concurrent requests ([4db1ccc](https://github.com/useplunk/plunk/commit/4db1ccc3fc59edb0187d8e2223f45bb0bce6deb5))
|
||||
* Remove 'Optional' label from MAIL FROM Domain and Inbound Email headings ([f9b1354](https://github.com/useplunk/plunk/commit/f9b135446040de099484db66139560f43bbf027e))
|
||||
* Update contact subscription logic for upsert operations ([a928666](https://github.com/useplunk/plunk/commit/a928666dfcdc65c90602051c79b3c008282674aa))
|
||||
* Update URL replacement logic to handle runtime paths and add warnings for missing files ([c07816c](https://github.com/useplunk/plunk/commit/c07816c2a1b029d83400128f4720263e975214ad))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove unnecessary logging for segment processing ([42ceb6e](https://github.com/useplunk/plunk/commit/42ceb6edc03ebc6f14c6d399e859914ac09f1ab6))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add webhooks documentation for real-time event handling ([5bce1d7](https://github.com/useplunk/plunk/commit/5bce1d74fffb927bcbb2c7df624fde089101efc8))
|
||||
* update contacts documentation to include subscription state and email delivery rules ([b4404f6](https://github.com/useplunk/plunk/commit/b4404f698ec28a59f32d728c15c59c8b7765d377))
|
||||
|
||||
## [0.4.0](https://github.com/useplunk/plunk/compare/v0.3.0...v0.4.0) (2026-01-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add cooldown to resend verification email ([457c829](https://github.com/useplunk/plunk/commit/457c829b2d59debc41ac69f907f758dd5ded1c1a))
|
||||
* Add email verification on signup ([fb02051](https://github.com/useplunk/plunk/commit/fb02051538029d8a6b806ce69b25fb9e75622693))
|
||||
* Add forwarding domains as verification check ([e732c76](https://github.com/useplunk/plunk/commit/e732c76490e015b87a9165a98f1f1b5084552f84))
|
||||
* Add id as reserved field in templates, campaigns and workflows ([7386441](https://github.com/useplunk/plunk/commit/7386441e6137ac9f059a3818895f1b1059e2d99d))
|
||||
* Add platform emails for domain verification and expiration ([19554e6](https://github.com/useplunk/plunk/commit/19554e6e8f94fbcf74006017454aa83a707617ea))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add better validation for sender email ([e75e07f](https://github.com/useplunk/plunk/commit/e75e07f73f5928ded281d2704b0fd06fedeb9077))
|
||||
* Catch unknown content-type headers ([a5c5754](https://github.com/useplunk/plunk/commit/a5c575444ba698624b3932b4d6414c5ad9df282a))
|
||||
* Check email volume for 7-day window ([492beb0](https://github.com/useplunk/plunk/commit/492beb095fd7be0cfd3de9761420d7ce1170d56f))
|
||||
* Copy types build files ([49824af](https://github.com/useplunk/plunk/commit/49824aff93c7ce09caa0cb57cfef68ae296f6626))
|
||||
* Enhance CORS handling to allow requests with rejection logging ([718251c](https://github.com/useplunk/plunk/commit/718251c67c876352a5dfca7592613e33f6713061))
|
||||
* Migrate over to new pagination format in dashboard ([8790c45](https://github.com/useplunk/plunk/commit/8790c45edc1374f9649b8438563fc8844a645367))
|
||||
* Reentry into segment not working after exit ([4dce71a](https://github.com/useplunk/plunk/commit/4dce71a1fe22774391bf0d0e87f1564c3b93b496))
|
||||
* Refactor CORS handling to allow unrestricted access for public API endpoints ([940c893](https://github.com/useplunk/plunk/commit/940c8938f163879da5be205bcc8bb82ecd69279a))
|
||||
* Update sentCount on campaign sent for correct overview stats ([ee00eb3](https://github.com/useplunk/plunk/commit/ee00eb34811270473d1f79729853eaada883a477))
|
||||
* Update template fetching to use Template type and simplify body access ([b6c5471](https://github.com/useplunk/plunk/commit/b6c5471d272e8ba835282691946a418385896c98))
|
||||
* Update templates data fetching to use PaginatedResponse type ([fa22b82](https://github.com/useplunk/plunk/commit/fa22b8220a909e7234948aeeb2a6734ae51aeec9))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add more details about personalisation ([940a4d2](https://github.com/useplunk/plunk/commit/940a4d225b86ba5af5377851ab758a43b3aa71ff))
|
||||
|
||||
## [0.3.0](https://github.com/useplunk/plunk/compare/v0.2.0...v0.3.0) (2025-12-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Ability to overwrite locale on contact level with locale key on data ([7615523](https://github.com/useplunk/plunk/commit/76155232a3383e75e8c7b44a498454b07472852a))
|
||||
* Add additional banner and information about security metrics ([bc8611a](https://github.com/useplunk/plunk/commit/bc8611a66250eb7747e7acd6e882a740c0028ba1))
|
||||
* Add bulk actions to contact overview ([726f667](https://github.com/useplunk/plunk/commit/726f66762b890c73041139432524d6c85d6bd709))
|
||||
* Add email verification and password reset ([1a5607f](https://github.com/useplunk/plunk/commit/1a5607f2780d5a4692492032dd0cd2e7521362d9))
|
||||
* Add email verification endpoint at /v1/verify ([6a9f6aa](https://github.com/useplunk/plunk/commit/6a9f6aa65a3219c5d4d6f33253cdcf145c3ff20b))
|
||||
* Add plus address check to /v1/verify ([afc405e](https://github.com/useplunk/plunk/commit/afc405ec028ac9d7333a7817c49f1232278fc28b))
|
||||
* Add project-scoped language for unsubscribe footer and contact-facing pages ([e1f8263](https://github.com/useplunk/plunk/commit/e1f826357d1e8cff7bd3c2811698734f578836f5))
|
||||
* Allow to pick currency when starting subscription ([8a136dd](https://github.com/useplunk/plunk/commit/8a136dde55fd1fae1f2a2e285019beb35fc75977))
|
||||
* Email preview in contact and activity feed ([72dffe1](https://github.com/useplunk/plunk/commit/72dffe12e53ff9d74ef43da2cf653d31e7a4df25))
|
||||
* **i18n:** add German translations and update supported languages ([a6bd2e7](https://github.com/useplunk/plunk/commit/a6bd2e7dba261a858eab6ab1f782ddc9efb136a5))
|
||||
* **i18n:** add Hindi translations for contact-facing pages ([ddc14ae](https://github.com/useplunk/plunk/commit/ddc14ae8534eda2e1368f148e0434388008bb7b5)), closes [#246](https://github.com/useplunk/plunk/issues/246)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add styling for visual editor emails in preview ([5993b84](https://github.com/useplunk/plunk/commit/5993b842a0f17d66644aaa3057602ae8f3daebc2))
|
||||
* Correctly reserve fields from being set on contact ([61cea95](https://github.com/useplunk/plunk/commit/61cea95697ccb56c525002b08f76bf57da896837))
|
||||
* Date filtering not working properly for custom contact data ([97ab0a2](https://github.com/useplunk/plunk/commit/97ab0a2c2c811ac1b8a2b9039ab835e837a3f3be))
|
||||
* Do not check verification if platform emails are not enabled ([9567144](https://github.com/useplunk/plunk/commit/9567144390512173f7f615db71368c1cd26d9f4d))
|
||||
* Import no longer case-sensitive about email column ([451dd03](https://github.com/useplunk/plunk/commit/451dd0327f4866fd27343407a84a6c979cfcd70d))
|
||||
* Pass through email verification if auth type is apiKey ([d7b5d3f](https://github.com/useplunk/plunk/commit/d7b5d3f60ed1af6ca9bf8e2a659204a01ca3acb0))
|
||||
* Persistence of subscription state for existing contacts ([007a908](https://github.com/useplunk/plunk/commit/007a908e833cdd1b229f485c34c17c6510a55f9c))
|
||||
* Properly tag events in SegmentFilterBuilder.tsx ([8f725c7](https://github.com/useplunk/plunk/commit/8f725c7c84749eca5647fdbd19d41260f6998d6e))
|
||||
* Redirect verification link to dashboard instead of landing ([35f5275](https://github.com/useplunk/plunk/commit/35f5275d889b167e0fe75246b29a4ffa632bad46))
|
||||
* Set auth type before disable check ([862babb](https://github.com/useplunk/plunk/commit/862babb8f5ab47599ce6a841fc988fafa1ec0bbe))
|
||||
* Variable substitution in transactional emails ([8c03042](https://github.com/useplunk/plunk/commit/8c0304273c2bd1a64718ec56838632aa63447ef8))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add locale overwrite to project documentation ([1fc1e23](https://github.com/useplunk/plunk/commit/1fc1e23fce69a870ccf95faf2fff644733de7145))
|
||||
* Add plus address check to /v1/verify ([0d54b1a](https://github.com/useplunk/plunk/commit/0d54b1a631415a15e504ff5bd4573ddb12cc421a))
|
||||
* Improve docs with core-concept and guides ([2ddfcec](https://github.com/useplunk/plunk/commit/2ddfceca3606b0d4d832f83fce14c7277b5eaa35))
|
||||
* Update openapi.json to match actual API outputs ([dc9b88d](https://github.com/useplunk/plunk/commit/dc9b88dedb75535814239b4bc73f62375570998b))
|
||||
|
||||
## [0.2.0](https://github.com/useplunk/plunk/compare/v0.1.1...v0.2.0) (2025-12-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ability to create new campaigns based on templates or previous campaigns ([6b25bbe](https://github.com/useplunk/plunk/commit/6b25bbe2f86e5dd6946ea38a9f34e9c7bdb5fb0f))
|
||||
* Add improved html editor using CodeMirror ([672f1e6](https://github.com/useplunk/plunk/commit/672f1e6657293860554be1f86167cf4f4403b0ff))
|
||||
* Add security center and warning for exceeding bounce/complaint rates ([fbd3038](https://github.com/useplunk/plunk/commit/fbd303801f9f1c260c5f8201bf218c9f277fe4d1))
|
||||
* Added platform emails for billing limits and disabled projects ([2485d2f](https://github.com/useplunk/plunk/commit/2485d2ff1db652e87f8f1307c8ef770edd19bbd1))
|
||||
* Automatically detect rate limit from AWS with ability to override in .env ([3225c50](https://github.com/useplunk/plunk/commit/3225c5005be42f1443fdca0f8233193ce029c890))
|
||||
* Improved createdAt and updatedAt visualisation ([1019ba0](https://github.com/useplunk/plunk/commit/1019ba0d82c7cb6a0d4cef5989841ccc28dae776))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ability to clear reply-to and from name from templates and campaigns ([badb035](https://github.com/useplunk/plunk/commit/badb035585561b276b6e82a363693918810c8214))
|
||||
* Add additional checks for disabled projects ([863e784](https://github.com/useplunk/plunk/commit/863e784e1c806118204acb3ba489322fe51498ad))
|
||||
* Add additional checks for disabled projects ([780741e](https://github.com/useplunk/plunk/commit/780741e37f7fec5b822b91c329ebe98428077ba0))
|
||||
* add additional indexes on event model ([1cd89d1](https://github.com/useplunk/plunk/commit/1cd89d137511ed4a8ae932a10f4f21487fbcee7c))
|
||||
* Allow changing audience type after creation of campaign ([85b9d7a](https://github.com/useplunk/plunk/commit/85b9d7afe718c803be147475d5fbe13dafd677bf))
|
||||
* Better highlight warnings in SecurityWarningBanner.tsx ([91eb0f3](https://github.com/useplunk/plunk/commit/91eb0f3a699eb7a51e87addfa122107ce74b4a39))
|
||||
* Clear notification cache keys when changing billing limits ([e50c33a](https://github.com/useplunk/plunk/commit/e50c33ae4b2d5144a1bfce72ae97d8ede0187d17))
|
||||
* Correctly show recipient count during creation and edit ([d0191be](https://github.com/useplunk/plunk/commit/d0191be31da8fc894269851a247746ce39a958b2))
|
||||
* custom relative time to shorten strings for better UI fit ([5e4adb7](https://github.com/useplunk/plunk/commit/5e4adb71ade38cf53e67776ae3524a381641fcfd))
|
||||
* display email progress instead of scheduling progress for campaigns ([07f875c](https://github.com/useplunk/plunk/commit/07f875c18c03a8d1766040bc40034c1023715fcd))
|
||||
* Hide upsell banner if billing is not configured ([433e796](https://github.com/useplunk/plunk/commit/433e796c041e8d65068084d5c7729c397956098e))
|
||||
* Increase z-index of color picker ([769e374](https://github.com/useplunk/plunk/commit/769e3748a0bbbcafdb1f79c9aea22d282e39b513))
|
||||
* Move react and react-dom to dependencies instead of peerDependency for API ([a555a12](https://github.com/useplunk/plunk/commit/a555a127366f753130c765f9eb4700dc44a8cb7c))
|
||||
* Move react and react-dom to dependencies instead of peerDependency for API ([f37bfcc](https://github.com/useplunk/plunk/commit/f37bfccbc22a0c2672971ff0e174f49f31301876))
|
||||
* Only check free tier limits if billing is enabled ([1713b23](https://github.com/useplunk/plunk/commit/1713b2398d5c238058639ac5a0d5cef916a7a1e8))
|
||||
* Only fetch project members if project Id is defined ([5a0a41c](https://github.com/useplunk/plunk/commit/5a0a41c6bbbb9bd4eefd5ed62e02c0a7c00c1022))
|
||||
* Overflow of inputs in email editor ([3336fa1](https://github.com/useplunk/plunk/commit/3336fa1e38a29d9c1f69c62259662b1ff2ba6e61))
|
||||
* Prevent manual tracking of internal events that are automatically tracked ([f34052e](https://github.com/useplunk/plunk/commit/f34052ed73cd78db4e4bf39cab8740ce0b78e93c))
|
||||
* prevent scheduling of campaign if billing limit reached ([a3bbb1e](https://github.com/useplunk/plunk/commit/a3bbb1ed64ba461cb2f7170c1929f68f22b5bb4d))
|
||||
* show correct default value for placeholder ([04275e3](https://github.com/useplunk/plunk/commit/04275e3cf59f902d50acb84e756f1c7425171726))
|
||||
* Unauthenticated users are redirected to login on subscribe/unsubscribe/manage pages ([11ef47b](https://github.com/useplunk/plunk/commit/11ef47b576ec39209f2b9726c1027d06f8bbc03a))
|
||||
* Update recipient count on create/update of campaign ([ff5c79c](https://github.com/useplunk/plunk/commit/ff5c79cfcf0ac6b351af20345ec639110f206f4b))
|
||||
* Verify if sending without tracking is possible in SESService ([68f9979](https://github.com/useplunk/plunk/commit/68f99798f8c5944c18b438329b961fdec955ecef))
|
||||
|
||||
## [0.1.1](https://github.com/useplunk/plunk/compare/v0.1.0...v0.1.1) (2025-12-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add additional verification in Oauth controllers ([53ecda9](https://github.com/useplunk/plunk/commit/53ecda9f7a34111785ac3ea3af18cb33460a44af))
|
||||
* add clear cache button on full-screen loader ([60804c1](https://github.com/useplunk/plunk/commit/60804c12b0b4c4c3ef97e730e4857d41fa1fd021))
|
||||
* Dedicated token name for next version ([fc535a5](https://github.com/useplunk/plunk/commit/fc535a558fab28045dae9ce978f483e58c72a24f))
|
||||
* only mark releases as latest ([9d8b4a5](https://github.com/useplunk/plunk/commit/9d8b4a59fbd42420bb595d2a44df93a29214121a))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* dynamic link to Docker Compose for self-hosting ([90f8170](https://github.com/useplunk/plunk/commit/90f8170efbad0cec981a24e7831840aac65d8d70))
|
||||
* Update AWS setup docs ([f7ac25f](https://github.com/useplunk/plunk/commit/f7ac25f91f4e1dcce301fb325801a67f2a9dee07))
|
||||
|
||||
## [0.1.0](https://github.com/useplunk/plunk/compare/v0.0.1...v0.1.0) (2025-12-08)
|
||||
|
||||
|
||||
|
||||
@@ -64,10 +64,10 @@ 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)
|
||||
- HTTP API endpoints for the platform
|
||||
- Background cron jobs (workflow processor, domain verification)
|
||||
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
||||
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
||||
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js app - Documentation site (docs.useplunk.com)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -145,6 +146,14 @@ Required for builds and deployment (see turbo.json and .env.example):
|
||||
- Stripe (optional): `STRIPE_SK`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ONBOARDING`, `STRIPE_PRICE_EMAIL_USAGE`,
|
||||
`STRIPE_METER_EVENT_NAME`
|
||||
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
||||
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
|
||||
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
|
||||
- Self-hosting User Management (optional):
|
||||
- `DISABLE_SIGNUPS` (default: false) - When set to true, prevents new user signups via the API
|
||||
- `VERIFY_EMAIL_ON_SIGNUP` (default: false) - When set to true, validates emails on signup for disposable domains,
|
||||
plus-addressing, domain existence, and MX records
|
||||
- Security (optional): `AUTO_PROJECT_DISABLE` (default: true) - Controls whether projects are automatically disabled when
|
||||
bounce/complaint rate thresholds are exceeded
|
||||
|
||||
**Important Notes:**
|
||||
|
||||
@@ -153,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.
|
||||
|
||||
+32
-4
@@ -95,8 +95,8 @@ 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 API_URI=https://next-api.useplunk.com
|
||||
ARG DASHBOARD_URI=https://next-app.useplunk.com
|
||||
ARG LANDING_URI=https://www.useplunk.com
|
||||
ARG WIKI_URI=https://docs.useplunk.com
|
||||
|
||||
@@ -123,6 +123,10 @@ COPY --from=deps /app/yarn.lock ./
|
||||
# Copy root config files needed for Turbo
|
||||
COPY turbo.json ./
|
||||
|
||||
# Copy manifest generation script
|
||||
COPY docker/generate-url-manifest.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/generate-url-manifest.sh
|
||||
|
||||
# Step 1: Copy and build shared packages (these change less frequently)
|
||||
# Shared packages are dependencies for apps, so build them first
|
||||
COPY packages ./packages
|
||||
@@ -171,6 +175,10 @@ RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
|
||||
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
|
||||
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
|
||||
yarn turbo build --filter=wiki
|
||||
# Generate sitemap for wiki
|
||||
RUN NEXT_PUBLIC_WIKI_URI=${WIKI_URI} yarn workspace wiki sitemap
|
||||
# Generate URL replacement manifest for wiki (build-time optimization)
|
||||
RUN generate-url-manifest.sh wiki /app/apps/wiki
|
||||
|
||||
# Step 4: Copy and build Web dashboard
|
||||
COPY apps/web ./apps/web
|
||||
@@ -184,6 +192,10 @@ RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
|
||||
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
|
||||
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
|
||||
yarn turbo build --filter=web
|
||||
# Generate sitemap for web
|
||||
RUN NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} yarn workspace web sitemap
|
||||
# Generate URL replacement manifest for web (build-time optimization)
|
||||
RUN generate-url-manifest.sh web /app/apps/web
|
||||
|
||||
# Step 5: Copy and build Landing page
|
||||
COPY apps/landing ./apps/landing
|
||||
@@ -197,6 +209,10 @@ RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
|
||||
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
|
||||
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
|
||||
yarn turbo build --filter=landing
|
||||
# Generate sitemap for landing
|
||||
RUN NEXT_PUBLIC_LANDING_URI=${LANDING_URI} yarn workspace landing sitemap
|
||||
# Generate URL replacement manifest for landing (build-time optimization)
|
||||
RUN generate-url-manifest.sh landing /app/apps/landing
|
||||
|
||||
# Copy any remaining root files (if needed)
|
||||
COPY . .
|
||||
@@ -257,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)
|
||||
@@ -284,17 +299,26 @@ COPY --from=builder --chown=plunk:nodejs /app/apps/smtp/package.json ./apps/smtp
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/standalone ./apps/web/.next/standalone
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/web/public ./apps/web/.next/standalone/apps/web/public
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/static ./apps/web/.next/standalone/apps/web/.next/static
|
||||
# Copy URL replacement manifests to standalone directory
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/url-manifest.txt ./apps/web/.next/standalone/apps/web/.next/url-manifest.txt
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/sitemap-manifest.txt ./apps/web/.next/standalone/apps/web/.next/sitemap-manifest.txt
|
||||
|
||||
# Landing app - standalone build with static assets
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/standalone ./apps/landing/.next/standalone
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/public ./apps/landing/.next/standalone/apps/landing/public
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/static ./apps/landing/.next/standalone/apps/landing/.next/static
|
||||
# Copy URL replacement manifests to standalone directory
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/url-manifest.txt ./apps/landing/.next/standalone/apps/landing/.next/url-manifest.txt
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/sitemap-manifest.txt ./apps/landing/.next/standalone/apps/landing/.next/sitemap-manifest.txt
|
||||
|
||||
# Wiki app - standalone build with static assets and OpenAPI spec
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/standalone ./apps/wiki/.next/standalone
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/public ./apps/wiki/.next/standalone/apps/wiki/public
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/static ./apps/wiki/.next/standalone/apps/wiki/.next/static
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/openapi.local.json ./apps/wiki/.next/standalone/apps/wiki/openapi.local.json
|
||||
# Copy URL replacement manifests to standalone directory
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/url-manifest.txt ./apps/wiki/.next/standalone/apps/wiki/.next/url-manifest.txt
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/sitemap-manifest.txt ./apps/wiki/.next/standalone/apps/wiki/.next/sitemap-manifest.txt
|
||||
|
||||
# Copy full .next directories for the entrypoint script (URL replacement via find command)
|
||||
# These are much smaller than node_modules and needed for runtime URL replacement
|
||||
@@ -311,6 +335,10 @@ COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/openapi.local.json ./app
|
||||
COPY --chown=plunk:nodejs docker/nginx/ /app/docker/nginx/
|
||||
RUN chmod +x /app/docker/nginx/setup-nginx.sh
|
||||
|
||||
# Copy optimized URL replacement script
|
||||
COPY --chown=plunk:nodejs docker/replace-urls-optimized.sh /app/docker/
|
||||
RUN chmod +x /app/docker/replace-urls-optimized.sh
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY --chown=plunk:nodejs docker-entrypoint-nginx.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint-nginx.sh
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/contributors/useplunk/plunk"/>
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/useplunk/plunk/docker-publish.yml"/>
|
||||
<img src="https://img.shields.io/github/license/useplunk/plunk"/>
|
||||
<img src="https://img.shields.io/github/stars/useplunk/plunk"/>
|
||||
<a href="https://github.com/useplunk/plunk/graphs/contributors"><img src="https://img.shields.io/github/contributors/useplunk/plunk" alt="Contributors"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/actions"><img src="https://img.shields.io/github/actions/workflow/status/useplunk/plunk/docker-publish.yml" alt="Build Status"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/blob/next/LICENSE"><img src="https://img.shields.io/github/license/useplunk/plunk" alt="License"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/stargazers"><img src="https://img.shields.io/github/stars/useplunk/plunk" alt="Stars"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/issues"><img src="https://img.shields.io/github/issues/useplunk/plunk" alt="Issues"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/network/members"><img src="https://img.shields.io/github/forks/useplunk/plunk" alt="Forks"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/pkgs/container/plunk"><img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker"/></a>
|
||||
<a href="https://github.com/sponsors/driaug"><img src="https://img.shields.io/badge/sponsor-❤-ff69b4" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
## Introduction
|
||||
@@ -47,7 +51,7 @@ the [documentation](https://docs.useplunk.com/self-hosting/introduction).
|
||||
You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
<a href="https://github.com/useplunk/plunk/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=useplunk/plunk" />
|
||||
<img src="https://contrib.rocks/image?repo=useplunk/plunk" alt="Contributors" />
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,6 +55,12 @@ AWS_SES_SECRET_ACCESS_KEY=
|
||||
SES_CONFIGURATION_SET=plunk-configuration-set # Default: with open/click tracking
|
||||
SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optional: without tracking (enables toggle in UI)
|
||||
|
||||
# Email sending rate limit (emails per second)
|
||||
# If not set, automatically fetches from AWS SES account quota (recommended)
|
||||
# Set this to override AWS quota (useful for setting lower limits or testing)
|
||||
# Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts)
|
||||
# EMAIL_RATE_LIMIT_PER_SECOND=14
|
||||
|
||||
# ==============================================================================
|
||||
# OAuth (Optional - for social login)
|
||||
# ==============================================================================
|
||||
@@ -70,3 +77,11 @@ STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_ONBOARDING= # Optional: One-time onboarding fee price ID (e.g., price_xxxxx)
|
||||
STRIPE_PRICE_EMAIL_USAGE= # Required: Metered price ID for pay-per-email billing
|
||||
STRIPE_METER_EVENT_NAME=emails # Meter event name (API key from your Stripe meter, default: emails)
|
||||
|
||||
# ==============================================================================
|
||||
# Security (Optional)
|
||||
# ==============================================================================
|
||||
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
|
||||
# Set to 'false' to disable automatic project suspension (useful for self-hosters who manage manually)
|
||||
# Default: true (automatic project disabling enabled)
|
||||
# AUTO_PROJECT_DISABLE=true
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@plunk/shared": "*",
|
||||
"@plunk/types": "*",
|
||||
"@react-email/render": "^2.0.0",
|
||||
"@zootools/email-spell-checker": "^1.12.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"bullmq": "^5.63.2",
|
||||
@@ -34,8 +35,9 @@
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"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';
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('Actions API Integration Tests', () => {
|
||||
it('should validate subject and body required when no template', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
from: 'test@example.com',
|
||||
// Missing subject, body, and template
|
||||
});
|
||||
|
||||
@@ -169,6 +170,7 @@ describe('Actions API Integration Tests', () => {
|
||||
it('should accept to as string (backward compatible)', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
from: 'test@example.com',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
});
|
||||
@@ -182,6 +184,7 @@ describe('Actions API Integration Tests', () => {
|
||||
name: 'Jane Doe',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
from: 'test@example.com',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
});
|
||||
@@ -194,6 +197,7 @@ describe('Actions API Integration Tests', () => {
|
||||
to: {
|
||||
email: 'test@example.com',
|
||||
},
|
||||
from: 'test@example.com',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
});
|
||||
@@ -204,6 +208,7 @@ describe('Actions API Integration Tests', () => {
|
||||
it('should accept to as array of strings', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: ['test1@example.com', 'test2@example.com'],
|
||||
from: 'test@example.com',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
});
|
||||
@@ -217,6 +222,7 @@ describe('Actions API Integration Tests', () => {
|
||||
{name: 'Jane Doe', email: 'test1@example.com'},
|
||||
{name: 'John Smith', email: 'test2@example.com'},
|
||||
],
|
||||
from: 'test@example.com',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
});
|
||||
@@ -227,6 +233,7 @@ describe('Actions API Integration Tests', () => {
|
||||
it('should accept to as mixed array of strings and objects', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: ['test1@example.com', {name: 'John Smith', email: 'test2@example.com'}],
|
||||
from: 'test@example.com',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
});
|
||||
@@ -442,4 +449,599 @@ describe('Actions API Integration Tests', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// RESERVED EVENT VALIDATION
|
||||
// ========================================
|
||||
describe('Reserved Event Validation', () => {
|
||||
describe('Email events (email.*)', () => {
|
||||
it('should reject email.sent event', () => {
|
||||
const result = ActionSchemas.track.safeParse({
|
||||
event: 'email.sent',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
// Schema allows it, but controller validation should reject
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify the error would be thrown by controller
|
||||
const error = new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: 'Event name "email.sent" is reserved for system use and cannot be manually tracked',
|
||||
code: 'reserved_event',
|
||||
received: 'email.sent',
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
|
||||
expect(error.code).toBe(422);
|
||||
expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR);
|
||||
expect(error.errors[0]?.code).toBe('reserved_event');
|
||||
});
|
||||
|
||||
it('should reject email.delivery event', () => {
|
||||
const error = new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: 'Event name "email.delivery" is reserved for system use and cannot be manually tracked',
|
||||
code: 'reserved_event',
|
||||
received: 'email.delivery',
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
|
||||
expect(error.code).toBe(422);
|
||||
expect(error.errors[0]?.field).toBe('event');
|
||||
});
|
||||
|
||||
it('should reject email.open event', () => {
|
||||
const error = new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: 'Event name "email.open" is reserved for system use and cannot be manually tracked',
|
||||
code: 'reserved_event',
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
|
||||
expect(error.code).toBe(422);
|
||||
});
|
||||
|
||||
it('should reject email.click event', () => {
|
||||
const error = new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: 'Event name "email.click" is reserved for system use and cannot be manually tracked',
|
||||
code: 'reserved_event',
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
|
||||
expect(error.code).toBe(422);
|
||||
});
|
||||
|
||||
it('should reject email.bounce event', () => {
|
||||
const error = new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: 'Event name "email.bounce" is reserved for system use and cannot be manually tracked',
|
||||
code: 'reserved_event',
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
|
||||
expect(error.code).toBe(422);
|
||||
});
|
||||
|
||||
it('should reject email.complaint event', () => {
|
||||
const error = new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: 'Event name "email.complaint" is reserved for system use and cannot be manually tracked',
|
||||
code: 'reserved_event',
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
|
||||
expect(error.code).toBe(422);
|
||||
});
|
||||
|
||||
it('should reject any email.* pattern', () => {
|
||||
const error = new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: 'Event name "email.custom" is reserved for system use and cannot be manually tracked',
|
||||
code: 'reserved_event',
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
|
||||
expect(error.code).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contact events', () => {
|
||||
it('should reject contact.subscribed event', () => {
|
||||
const error = new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: 'Event name "contact.subscribed" is reserved for system use and cannot be manually tracked',
|
||||
code: 'reserved_event',
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
|
||||
expect(error.code).toBe(422);
|
||||
expect(error.errors[0]?.field).toBe('event');
|
||||
});
|
||||
|
||||
it('should reject contact.unsubscribed event', () => {
|
||||
const error = new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: 'Event name "contact.unsubscribed" is reserved for system use and cannot be manually tracked',
|
||||
code: 'reserved_event',
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
|
||||
expect(error.code).toBe(422);
|
||||
});
|
||||
|
||||
it('should allow other contact.* events', () => {
|
||||
const result1 = ActionSchemas.track.safeParse({
|
||||
event: 'contact.created',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
const result2 = ActionSchemas.track.safeParse({
|
||||
event: 'contact.updated',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
expect(result1.success).toBe(true);
|
||||
expect(result2.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Segment events', () => {
|
||||
it('should reject segment.*.entry events', () => {
|
||||
const error = new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: 'Event name "segment.vip-users.entry" is reserved for system use and cannot be manually tracked',
|
||||
code: 'reserved_event',
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
|
||||
expect(error.code).toBe(422);
|
||||
expect(error.errors[0]?.code).toBe('reserved_event');
|
||||
});
|
||||
|
||||
it('should reject segment.*.exit events', () => {
|
||||
const error = new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: 'Event name "segment.premium.exit" is reserved for system use and cannot be manually tracked',
|
||||
code: 'reserved_event',
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
|
||||
expect(error.code).toBe(422);
|
||||
});
|
||||
|
||||
it('should allow other segment.* events', () => {
|
||||
const result1 = ActionSchemas.track.safeParse({
|
||||
event: 'segment.created',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
const result2 = ActionSchemas.track.safeParse({
|
||||
event: 'segment.premium.updated',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
expect(result1.success).toBe(true);
|
||||
expect(result2.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom user events', () => {
|
||||
it('should allow custom user events', () => {
|
||||
const testCases = [
|
||||
'user.signup',
|
||||
'purchase.completed',
|
||||
'order.placed',
|
||||
'custom.event',
|
||||
'product.viewed',
|
||||
'cart.abandoned',
|
||||
];
|
||||
|
||||
for (const eventName of testCases) {
|
||||
const result = ActionSchemas.track.safeParse({
|
||||
event: eventName,
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow events with similar but different prefixes', () => {
|
||||
const testCases = ['emails.sent', 'contacts.subscribed', 'segments.entry'];
|
||||
|
||||
for (const eventName of testCases) {
|
||||
const result = ActionSchemas.track.safeParse({
|
||||
event: eventName,
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error structure for reserved events', () => {
|
||||
it('should return ValidationError with correct structure', () => {
|
||||
const error = new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: 'Event name "email.sent" is reserved for system use and cannot be manually tracked',
|
||||
code: 'reserved_event',
|
||||
received: 'email.sent',
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(ValidationError);
|
||||
expect(error.code).toBe(422);
|
||||
expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR);
|
||||
expect(error.message).toBe('Cannot track reserved system event');
|
||||
expect(error.errors).toHaveLength(1);
|
||||
expect(error.errors[0]).toMatchObject({
|
||||
field: 'event',
|
||||
code: 'reserved_event',
|
||||
received: 'email.sent',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// SUBSCRIPTION STATUS PRESERVATION
|
||||
// ========================================
|
||||
describe('Subscription Status Preservation', () => {
|
||||
describe('/v1/send endpoint', () => {
|
||||
it('should NOT change subscription status when sending to subscribed contact without subscribed field', async () => {
|
||||
// Create a subscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
email: 'subscribed@example.com',
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(contact.subscribed).toBe(true);
|
||||
|
||||
// Send transactional email without specifying subscribed field
|
||||
await EmailService.sendTransactionalEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
});
|
||||
|
||||
// Verify subscription status unchanged
|
||||
const updatedContact = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updatedContact?.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT change subscription status when sending to unsubscribed contact without subscribed field', async () => {
|
||||
// Create an unsubscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'unsubscribed@example.com',
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(contact.subscribed).toBe(false);
|
||||
|
||||
// Send transactional email without specifying subscribed field
|
||||
await EmailService.sendTransactionalEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
});
|
||||
|
||||
// Verify subscription status unchanged (should still be false)
|
||||
const updatedContact = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updatedContact?.subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow explicit subscription when subscribed=true is provided', async () => {
|
||||
// Create an unsubscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'resubscribe@example.com',
|
||||
});
|
||||
|
||||
// This test would need to be implemented at the controller level
|
||||
// since EmailService.sendTransactionalEmail doesn't accept subscribed parameter
|
||||
// For now, verify the schema allows it
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: contact.email,
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
subscribed: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.subscribed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow explicit unsubscription when subscribed=false is provided', async () => {
|
||||
// Verify the schema allows explicit false
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.subscribed).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should default to undefined when subscribed field is omitted', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
// Should be undefined, not false
|
||||
expect(result.data.subscribed).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should create new contacts as unsubscribed when subscribed is undefined', async () => {
|
||||
const newEmail = 'new-send-contact@example.com';
|
||||
|
||||
// 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', () => {
|
||||
it('should NOT change subscription status when tracking event for subscribed contact', async () => {
|
||||
// Create a subscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
email: 'track-subscribed@example.com',
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(contact.subscribed).toBe(true);
|
||||
|
||||
// Track event without specifying subscribed field
|
||||
// This would be done via ContactService.upsert in the track endpoint
|
||||
const {ContactService} = await import('../../services/ContactService.js');
|
||||
await ContactService.upsert(projectId, contact.email, {event: 'test'}, undefined);
|
||||
|
||||
// Verify subscription status unchanged
|
||||
const updatedContact = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updatedContact?.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT re-subscribe unsubscribed contact when tracking event', async () => {
|
||||
// Create an unsubscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'track-unsubscribed@example.com',
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(contact.subscribed).toBe(false);
|
||||
|
||||
// Track event without specifying subscribed field
|
||||
const {ContactService} = await import('../../services/ContactService.js');
|
||||
await ContactService.upsert(projectId, contact.email, {event: 'test'}, undefined);
|
||||
|
||||
// Verify subscription status unchanged (should still be false, NOT re-subscribed)
|
||||
const updatedContact = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updatedContact?.subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should create new contacts as subscribed when subscribed is undefined', async () => {
|
||||
const newEmail = 'new-track-contact@example.com';
|
||||
|
||||
// Track event for new contact without specifying subscribed
|
||||
const {ContactService} = await import('../../services/ContactService.js');
|
||||
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, true);
|
||||
|
||||
// Event tracking should create contacts as subscribed by default
|
||||
expect(contact.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow explicit subscription when subscribed=true is provided', async () => {
|
||||
const result = ActionSchemas.track.safeParse({
|
||||
event: 'test',
|
||||
email: 'test@example.com',
|
||||
subscribed: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.subscribed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow explicit unsubscription when subscribed=false is provided', async () => {
|
||||
const result = ActionSchemas.track.safeParse({
|
||||
event: 'test',
|
||||
email: 'test@example.com',
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.subscribed).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should default to undefined when subscribed field is omitted', () => {
|
||||
const result = ActionSchemas.track.safeParse({
|
||||
event: 'test',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
// Should be undefined, not true
|
||||
expect(result.data.subscribed).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactService.upsert behavior', () => {
|
||||
it('should preserve subscription status when undefined is passed for existing contact', async () => {
|
||||
// Create a subscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
email: 'upsert-test@example.com',
|
||||
});
|
||||
|
||||
const {ContactService} = await import('../../services/ContactService.js');
|
||||
|
||||
// Update with undefined subscribed
|
||||
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, undefined);
|
||||
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updated?.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve unsubscribed status when undefined is passed', async () => {
|
||||
// Create an unsubscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'upsert-unsub@example.com',
|
||||
});
|
||||
|
||||
const {ContactService} = await import('../../services/ContactService.js');
|
||||
|
||||
// Update with undefined subscribed
|
||||
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, undefined);
|
||||
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updated?.subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow explicit subscription change to true', async () => {
|
||||
// Create an unsubscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'explicit-sub@example.com',
|
||||
});
|
||||
|
||||
const {ContactService} = await import('../../services/ContactService.js');
|
||||
|
||||
// Explicitly subscribe
|
||||
await ContactService.upsert(projectId, contact.email, {}, true);
|
||||
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updated?.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow explicit subscription change to false', async () => {
|
||||
// Create a subscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
email: 'explicit-unsub@example.com',
|
||||
});
|
||||
|
||||
const {ContactService} = await import('../../services/ContactService.js');
|
||||
|
||||
// Explicitly unsubscribe
|
||||
await ContactService.upsert(projectId, contact.email, {}, false);
|
||||
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updated?.subscribed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
+94
-10
@@ -14,6 +14,7 @@ import {
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
LANDING_URI,
|
||||
NODE_ENV,
|
||||
PLUNK_ENABLED,
|
||||
PORT,
|
||||
S3_ENABLED,
|
||||
SMTP_ENABLED,
|
||||
@@ -75,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'));
|
||||
|
||||
@@ -153,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 => ({
|
||||
@@ -309,7 +388,7 @@ server.app.use((error: Error, req: Request, res: Response, _next: NextFunction)
|
||||
// Global error handlers to prevent server crashes
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
signale.error('Unhandled Promise Rejection:', reason);
|
||||
console.error('Promise:', promise);
|
||||
signale.error('Promise:', promise);
|
||||
// Don't exit the process - just log the error
|
||||
});
|
||||
|
||||
@@ -351,6 +430,11 @@ void prisma.$connect().then(async () => {
|
||||
? 'Per-project tracking toggle enabled'
|
||||
: 'Always tracking or always no-tracking',
|
||||
},
|
||||
{
|
||||
name: 'Platform emails',
|
||||
enabled: PLUNK_ENABLED,
|
||||
details: PLUNK_ENABLED ? 'Platform email notifications enabled' : 'PLUNK_API_KEY not configured',
|
||||
},
|
||||
];
|
||||
|
||||
const rows = features.map(f => ({
|
||||
|
||||
@@ -45,6 +45,13 @@ export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
|
||||
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
|
||||
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
|
||||
|
||||
// Email Processing Rate Limit (optional override)
|
||||
// If not set, will automatically fetch from AWS SES account quota
|
||||
// Set this to override AWS quota (useful for setting lower limits or testing)
|
||||
export const EMAIL_RATE_LIMIT_PER_SECOND = process.env.EMAIL_RATE_LIMIT_PER_SECOND
|
||||
? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND)
|
||||
: undefined;
|
||||
|
||||
// Storage
|
||||
export const REDIS_URL = validateEnv('REDIS_URL');
|
||||
export const DATABASE_URL = validateEnv('DATABASE_URL');
|
||||
@@ -87,3 +94,24 @@ export const SMTP_PORT_SUBMISSION = Number(validateEnv('PORT_SUBMISSION', '587')
|
||||
// Enable SMTP features only when explicitly enabled via env or when a non-default domain is configured
|
||||
export const SMTP_ENABLED =
|
||||
process.env.SMTP_ENABLED === 'true' || (SMTP_DOMAIN !== 'localhost' && NODE_ENV !== 'development');
|
||||
|
||||
export const PLUNK_API_KEY = validateEnv('PLUNK_API_KEY', '');
|
||||
export const PLUNK_FROM_ADDRESS = validateEnv('PLUNK_FROM_ADDRESS', '');
|
||||
export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
|
||||
|
||||
// Security (optional)
|
||||
// Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
|
||||
// Useful for self-hosters who want to manage project status manually
|
||||
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
|
||||
|
||||
// Self-hosting Configuration (optional)
|
||||
// Controls whether new user signups are allowed (default: false)
|
||||
export const DISABLE_SIGNUPS = process.env.DISABLE_SIGNUPS === 'true';
|
||||
// Controls whether email validation checks are performed on signup (default: false)
|
||||
export const VERIFY_EMAIL_ON_SIGNUP = process.env.VERIFY_EMAIL_ON_SIGNUP === 'true';
|
||||
|
||||
// Email Verification & Password Reset
|
||||
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
|
||||
export const PASSWORD_RESET_RATE_LIMIT = 3; // Max 3 emails per hour
|
||||
export const EMAIL_VERIFICATION_RATE_WINDOW = 3600; // 1 hour in seconds
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {Controller, Middleware, Post} from '@overnightjs/core';
|
||||
import {ActionSchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requirePublicKey, requireSecretKey} from '../middleware/auth.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {EmailService} from '../services/EmailService.js';
|
||||
import {EmailVerificationService} from '../services/EmailVerificationService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {NotFound} from '../exceptions/index.js';
|
||||
import {NotFound, ValidationError} from '../exceptions/index.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
import {DASHBOARD_URI} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
* Public API Actions Controller
|
||||
* Handles track event and transactional email endpoints
|
||||
* Handles track event, transactional email, and email verification endpoints
|
||||
*/
|
||||
@Controller('v1')
|
||||
export class Actions {
|
||||
@@ -25,7 +25,7 @@ export class Actions {
|
||||
* Request body:
|
||||
* - event: string (required) - Event name
|
||||
* - email: string (required) - Contact email
|
||||
* - subscribed: boolean (optional, default: true) - Contact subscription status
|
||||
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
|
||||
* - data: object (optional) - Event and contact data
|
||||
* - Simple values are saved to contact (persistent)
|
||||
* - {value: any, persistent: false} are only available to workflows (non-persistent)
|
||||
@@ -50,13 +50,30 @@ 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);
|
||||
|
||||
// Prevent manual tracking of reserved system events
|
||||
if (EventService.isReservedEvent(event)) {
|
||||
throw new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: `Event name "${event}" is reserved for system use and cannot be manually tracked`,
|
||||
code: 'reserved_event',
|
||||
received: event,
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
}
|
||||
|
||||
// Create or update contact with persistent data only
|
||||
// ContactService.upsert will filter out non-persistent fields
|
||||
// Event tracking should subscribe new contacts by default (subscribed=true in ContactService)
|
||||
// but preserve existing subscription state for existing contacts
|
||||
const contact = await ContactService.upsert(
|
||||
auth.projectId,
|
||||
email,
|
||||
@@ -95,7 +112,7 @@ export class Actions {
|
||||
* - Array: ["user1@example.com", {name: "Jane", email: "user2@example.com"}]
|
||||
* - subject: string (required) - Email subject
|
||||
* - body: string (required) - Email HTML body
|
||||
* - subscribed: boolean (optional, default: false) - Contact subscription status
|
||||
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
|
||||
* - name: string (optional) - Sender name (alternative to from.name)
|
||||
* - from: string | object (optional) - Sender email or {name, email} object (must be from verified domain)
|
||||
* - reply: string (optional) - Reply-to email
|
||||
@@ -156,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} =
|
||||
@@ -223,14 +240,21 @@ export class Actions {
|
||||
templateId = templateRecord.id;
|
||||
}
|
||||
|
||||
// Verify 'from' domain is verified if provided
|
||||
const senderEmail = emailFrom || 'noreply@useplunk.com'; // Default sender
|
||||
|
||||
// Only verify custom domains (not the default noreply@useplunk.com)
|
||||
if (emailFrom && emailFrom !== 'noreply@useplunk.com') {
|
||||
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
|
||||
if (!emailFrom) {
|
||||
throw new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'from',
|
||||
message: 'Sender email is required either in request or template',
|
||||
code: 'required',
|
||||
},
|
||||
],
|
||||
'Could not parse sender email',
|
||||
);
|
||||
}
|
||||
|
||||
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
|
||||
|
||||
const replyToEmail = emailReplyTo;
|
||||
|
||||
const timestamp = new Date();
|
||||
@@ -244,17 +268,32 @@ 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);
|
||||
|
||||
// Add system variables (email, unsubscribe URLs, etc.) to merged data
|
||||
// These are always available for template rendering
|
||||
const dataWithSystemVars = {
|
||||
...mergedData,
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
|
||||
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
|
||||
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
|
||||
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
|
||||
};
|
||||
|
||||
// Render template with contact data
|
||||
// Simple template variable replacement: {{fieldname}}
|
||||
let renderedSubject = emailSubject!;
|
||||
let renderedBody = emailBody!;
|
||||
|
||||
for (const [key, value] of Object.entries(mergedData)) {
|
||||
for (const [key, value] of Object.entries(dataWithSystemVars)) {
|
||||
const placeholder = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
|
||||
const fallbackPlaceholder = new RegExp(`\\{\\{\\s*${key}\\s*\\?\\?\\s*([^}]+)\\}\\}`, 'g');
|
||||
|
||||
@@ -281,7 +320,7 @@ export class Actions {
|
||||
contactId: contact.id,
|
||||
subject: renderedSubject,
|
||||
body: renderedBody,
|
||||
from: senderEmail,
|
||||
from: emailFrom,
|
||||
fromName: emailFromName,
|
||||
toName: recipient.name,
|
||||
replyTo: replyToEmail,
|
||||
@@ -307,4 +346,58 @@ export class Actions {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/verify
|
||||
* Verify an email address
|
||||
*
|
||||
* Request body:
|
||||
* - email: string (required) - Email address to verify
|
||||
*
|
||||
* Response:
|
||||
* - success: boolean
|
||||
* - data: object with verification results
|
||||
* - email: string - Email address that was verified
|
||||
* - valid: boolean - Whether the email appears to be valid
|
||||
* - isDisposable: boolean - Whether the email is from a disposable domain
|
||||
* - hasMxRecords: boolean - Whether the domain has MX records configured
|
||||
* - suggestedEmail?: string - Suggested correction if typo detected
|
||||
* - reasons: string[] - Array of reasons describing the verification results
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* email: "user@gmial.com"
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* email: "user@gmial.com",
|
||||
* valid: false,
|
||||
* isDisposable: false,
|
||||
* hasMxRecords: false,
|
||||
* suggestedEmail: "user@gmail.com",
|
||||
* reasons: [
|
||||
* "Possible typo detected, did you mean user@gmail.com?",
|
||||
* "Domain does not exist or has no MX records"
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
@Post('verify')
|
||||
@Middleware([requireSecretKey])
|
||||
@CatchAsync
|
||||
public async verify(req: Request, res: Response, _next: NextFunction) {
|
||||
// Zod validation - errors automatically handled by global error handler
|
||||
const {email} = ActionSchemas.verify.parse(req.body);
|
||||
|
||||
// Verify the email address
|
||||
const verificationResult = await EmailVerificationService.verifyEmail(email);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: verificationResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {requireAuth} from '../middleware/auth.js';
|
||||
import {ActivityService, ActivityType} from '../services/ActivityService.js';
|
||||
import {ActivityType} from '@plunk/types';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {ActivityService} from '../services/ActivityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('activity')
|
||||
@@ -21,10 +20,10 @@ export class Activity {
|
||||
* - endDate: ISO date string
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
@@ -62,10 +61,10 @@ export class Activity {
|
||||
* - endDate: ISO date string (defaults to now)
|
||||
*/
|
||||
@Get('stats')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -82,10 +81,10 @@ export class Activity {
|
||||
* - minutes: number (default 5)
|
||||
*/
|
||||
@Get('recent-count')
|
||||
@Middleware([requireAuth])
|
||||
@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);
|
||||
@@ -98,7 +97,7 @@ export class Activity {
|
||||
* Get available activity types (for UI filters)
|
||||
*/
|
||||
@Get('types')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getTypes(_req: Request, res: Response, _next: NextFunction) {
|
||||
const types = Object.values(ActivityType);
|
||||
@@ -114,10 +113,10 @@ export class Activity {
|
||||
* - daysAhead: number (default 30, max 90)
|
||||
*/
|
||||
@Get('upcoming')
|
||||
@Middleware([requireAuth])
|
||||
@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,8 +1,6 @@
|
||||
import {Controller, Get, Middleware} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {AnalyticsService} from '../services/AnalyticsService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@@ -19,10 +17,10 @@ export class Analytics {
|
||||
* Returns daily aggregated email metrics (sent, opened, clicked, bounced, delivered)
|
||||
*/
|
||||
@Get('timeseries')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -41,10 +39,10 @@ export class Analytics {
|
||||
* - endDate: ISO date string (defaults to now)
|
||||
*/
|
||||
@Get('top-campaigns')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
@@ -65,10 +63,10 @@ export class Analytics {
|
||||
* Returns aggregate stats: total campaigns, active, completed, average rates
|
||||
*/
|
||||
@Get('campaign-stats')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -89,10 +87,10 @@ export class Analytics {
|
||||
* Returns events sorted by frequency with trend data
|
||||
*/
|
||||
@Get('top-events')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import {Controller, Get, Post} from '@overnightjs/core';
|
||||
import {AuthenticationSchemas} from '@plunk/shared';
|
||||
import {EmailVerificationEmail, PasswordResetEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import * as React from 'react';
|
||||
|
||||
import {GITHUB_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED} from '../app/constants.js';
|
||||
import {
|
||||
DASHBOARD_URI,
|
||||
DISABLE_SIGNUPS,
|
||||
EMAIL_VERIFICATION_RATE_LIMIT,
|
||||
EMAIL_VERIFICATION_RATE_WINDOW,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
LANDING_URI,
|
||||
PASSWORD_RESET_RATE_LIMIT,
|
||||
PLUNK_ENABLED,
|
||||
TOKEN_EXPIRY_SECONDS,
|
||||
VERIFY_EMAIL_ON_SIGNUP,
|
||||
} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
||||
import {jwt} from '../middleware/auth.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';
|
||||
@@ -48,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) {
|
||||
@@ -64,6 +116,8 @@ export class Auth {
|
||||
email,
|
||||
password: await AuthService.generateHash(password),
|
||||
type: 'PASSWORD',
|
||||
// Auto-verify email if platform emails are disabled
|
||||
emailVerified: !PLUNK_ENABLED,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -72,6 +126,27 @@ export class Auth {
|
||||
// Send notification about new user signup
|
||||
await NtfyService.notifyUserSignup(created_user.email, created_user.id);
|
||||
|
||||
// Send email verification if platform emails are enabled
|
||||
if (PLUNK_ENABLED) {
|
||||
const verificationToken = randomBytes(32).toString('hex');
|
||||
await redis.setex(
|
||||
Keys.User.emailVerificationToken(verificationToken),
|
||||
TOKEN_EXPIRY_SECONDS,
|
||||
JSON.stringify({userId: created_user.id, email: created_user.email, createdAt: Date.now()}),
|
||||
);
|
||||
|
||||
const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${verificationToken}`;
|
||||
await sendPlatformEmail(
|
||||
created_user.email,
|
||||
'Verify your email address',
|
||||
React.createElement(EmailVerificationEmail, {
|
||||
email: created_user.email,
|
||||
verificationUrl,
|
||||
landingUrl: LANDING_URI,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const token = jwt.sign(created_user.id);
|
||||
const cookie = UserService.cookieOptions();
|
||||
|
||||
@@ -97,4 +172,159 @@ export class Auth {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Post('verify-email')
|
||||
@CatchAsync
|
||||
public async verifyEmail(req: Request, res: Response, _next: NextFunction) {
|
||||
const {token} = AuthenticationSchemas.verifyEmail.parse(req.body);
|
||||
|
||||
// Look up token in Redis
|
||||
const data = await redis.get(Keys.User.emailVerificationToken(token));
|
||||
|
||||
if (!data) {
|
||||
throw new BadRequest('Invalid or expired verification token');
|
||||
}
|
||||
|
||||
const {userId} = JSON.parse(data);
|
||||
|
||||
// Update user
|
||||
await prisma.user.update({
|
||||
where: {id: userId},
|
||||
data: {emailVerified: true},
|
||||
});
|
||||
|
||||
// Delete token (single use) and invalidate cache
|
||||
await redis.del(Keys.User.emailVerificationToken(token));
|
||||
await redis.del(Keys.User.id(userId));
|
||||
|
||||
return res.json({success: true, data: {message: 'Email verified successfully'}});
|
||||
}
|
||||
|
||||
@Post('request-verification')
|
||||
@CatchAsync
|
||||
public async requestVerification(req: Request, res: Response, _next: NextFunction) {
|
||||
const userId = parseJwt(req);
|
||||
const user = await UserService.id(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return res.json({success: true, data: {message: 'Email already verified'}});
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitKey = Keys.User.emailVerificationRateLimit(userId);
|
||||
const count = await redis.get(rateLimitKey);
|
||||
|
||||
if (count && parseInt(count) >= EMAIL_VERIFICATION_RATE_LIMIT) {
|
||||
throw new RateLimitError('Too many verification emails sent. Please try again later.');
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await redis.setex(
|
||||
Keys.User.emailVerificationToken(token),
|
||||
TOKEN_EXPIRY_SECONDS,
|
||||
JSON.stringify({userId, email: user.email, createdAt: Date.now()}),
|
||||
);
|
||||
|
||||
// Send email
|
||||
const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${token}`;
|
||||
await sendPlatformEmail(
|
||||
user.email,
|
||||
'Verify your email address',
|
||||
React.createElement(EmailVerificationEmail, {email: user.email, verificationUrl, landingUrl: LANDING_URI}),
|
||||
);
|
||||
|
||||
// Increment rate limit
|
||||
if (count) {
|
||||
await redis.incr(rateLimitKey);
|
||||
} else {
|
||||
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
|
||||
}
|
||||
|
||||
return res.json({success: true, data: {message: 'Verification email sent'}});
|
||||
}
|
||||
|
||||
@Post('request-password-reset')
|
||||
@CatchAsync
|
||||
public async requestPasswordReset(req: Request, res: Response, _next: NextFunction) {
|
||||
const {email} = AuthenticationSchemas.requestPasswordReset.parse(req.body);
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitKey = Keys.User.passwordResetRateLimit(email);
|
||||
const count = await redis.get(rateLimitKey);
|
||||
|
||||
if (count && parseInt(count) >= PASSWORD_RESET_RATE_LIMIT) {
|
||||
// Still return success to prevent enumeration
|
||||
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
|
||||
}
|
||||
|
||||
// Look up user
|
||||
const user = await UserService.email(email);
|
||||
|
||||
// Only send email if user exists and is PASSWORD type
|
||||
if (user && user.type === 'PASSWORD') {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await redis.setex(
|
||||
Keys.User.passwordResetToken(token),
|
||||
TOKEN_EXPIRY_SECONDS,
|
||||
JSON.stringify({userId: user.id, email: user.email, createdAt: Date.now()}),
|
||||
);
|
||||
|
||||
const resetUrl = `${DASHBOARD_URI}/auth/reset-password?token=${token}`;
|
||||
await sendPlatformEmail(
|
||||
user.email,
|
||||
'Reset your password',
|
||||
React.createElement(PasswordResetEmail, {email: user.email, resetUrl, landingUrl: LANDING_URI}),
|
||||
);
|
||||
|
||||
// Increment rate limit
|
||||
if (count) {
|
||||
await redis.incr(rateLimitKey);
|
||||
} else {
|
||||
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
|
||||
}
|
||||
}
|
||||
|
||||
// Always return success (prevent enumeration)
|
||||
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
|
||||
}
|
||||
|
||||
@Post('reset-password')
|
||||
@CatchAsync
|
||||
public async resetPassword(req: Request, res: Response, _next: NextFunction) {
|
||||
const {token, newPassword} = AuthenticationSchemas.resetPassword.parse(req.body);
|
||||
|
||||
// Look up token
|
||||
const data = await redis.get(Keys.User.passwordResetToken(token));
|
||||
|
||||
if (!data) {
|
||||
throw new BadRequest('Invalid or expired reset token');
|
||||
}
|
||||
|
||||
const {userId} = JSON.parse(data);
|
||||
|
||||
// Get user and verify type
|
||||
const user = await prisma.user.findUnique({where: {id: userId}});
|
||||
|
||||
if (!user || user.type !== 'PASSWORD') {
|
||||
throw new BadRequest('Invalid reset token');
|
||||
}
|
||||
|
||||
// Hash new password and update
|
||||
const hashedPassword = await AuthService.generateHash(newPassword);
|
||||
await prisma.user.update({
|
||||
where: {id: userId},
|
||||
data: {password: hashedPassword},
|
||||
});
|
||||
|
||||
// Delete token and invalidate cache
|
||||
await redis.del(Keys.User.passwordResetToken(token));
|
||||
await redis.del(Keys.User.id(userId));
|
||||
|
||||
return res.json({success: true, data: {message: 'Password reset successfully'}});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import {CampaignSchemas} from '@plunk/shared';
|
||||
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} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -17,14 +16,13 @@ export class Campaigns {
|
||||
* POST /campaigns
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([requireAuth])
|
||||
@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');
|
||||
}
|
||||
@@ -60,10 +58,10 @@ export class Campaigns {
|
||||
* GET /campaigns
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth])
|
||||
@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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,11 +85,11 @@ export class Campaigns {
|
||||
* GET /campaigns/:id
|
||||
*/
|
||||
@Get(':id')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const campaign = await CampaignService.get(auth.projectId, id!);
|
||||
|
||||
@@ -112,11 +104,11 @@ export class Campaigns {
|
||||
* PUT /campaigns/:id
|
||||
*/
|
||||
@Put(':id')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
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;
|
||||
|
||||
@@ -158,11 +150,11 @@ export class Campaigns {
|
||||
* DELETE /campaigns/:id
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
await CampaignService.delete(auth.projectId, id!);
|
||||
|
||||
@@ -177,11 +169,11 @@ export class Campaigns {
|
||||
* POST /campaigns/:id/duplicate
|
||||
*/
|
||||
@Post(':id/duplicate')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const campaign = await CampaignService.duplicate(auth.projectId, id!);
|
||||
|
||||
@@ -197,11 +189,11 @@ export class Campaigns {
|
||||
* POST /campaigns/:id/send
|
||||
*/
|
||||
@Post(':id/send')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async send(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const scheduledFor = req.body?.scheduledFor;
|
||||
|
||||
// Parse scheduledFor if provided
|
||||
@@ -228,11 +220,11 @@ export class Campaigns {
|
||||
* POST /campaigns/:id/cancel
|
||||
*/
|
||||
@Post(':id/cancel')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const campaign = await CampaignService.cancel(auth.projectId, id!);
|
||||
|
||||
@@ -248,11 +240,11 @@ export class Campaigns {
|
||||
* GET /campaigns/:id/stats
|
||||
*/
|
||||
@Get(':id/stats')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async stats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const stats = await CampaignService.getStats(auth.projectId, id!);
|
||||
|
||||
@@ -267,11 +259,11 @@ export class Campaigns {
|
||||
* POST /campaigns/:id/test
|
||||
*/
|
||||
@Post(':id/test')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const {email} = CampaignSchemas.sendTest.parse(req.body);
|
||||
|
||||
await CampaignService.sendTest(auth.projectId, id!, email);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import multer from 'multer';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth} from '../middleware/auth.js';
|
||||
import signale from 'signale';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {QueueService} from '../services/QueueService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -15,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 {
|
||||
@@ -31,10 +29,10 @@ export class Contacts {
|
||||
* List all contacts for the authenticated project with cursor-based pagination
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
@@ -50,10 +48,10 @@ export class Contacts {
|
||||
* Returns field names with inferred types (string, number, boolean, date)
|
||||
*/
|
||||
@Get('fields')
|
||||
@Middleware([requireAuth])
|
||||
@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!);
|
||||
@@ -63,7 +61,7 @@ export class Contacts {
|
||||
count: fieldsWithTypes.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to get available fields:', error);
|
||||
signale.error('[CONTACTS] Failed to get available fields:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
||||
});
|
||||
@@ -76,10 +74,10 @@ export class Contacts {
|
||||
* Example: /contacts/fields/data.plan/values or /contacts/fields/subscribed/values
|
||||
*/
|
||||
@Get('fields/:field/values')
|
||||
@Middleware([requireAuth])
|
||||
@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);
|
||||
|
||||
@@ -97,7 +95,7 @@ export class Contacts {
|
||||
limit,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to get field values:', error);
|
||||
signale.error('[CONTACTS] Failed to get field values:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get field values',
|
||||
});
|
||||
@@ -109,10 +107,10 @@ export class Contacts {
|
||||
* Get a specific contact by ID
|
||||
*/
|
||||
@Get(':id')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -129,10 +127,10 @@ export class Contacts {
|
||||
* Create or update a contact (upsert)
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -159,10 +157,10 @@ export class Contacts {
|
||||
* Update a contact
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -180,10 +178,10 @@ export class Contacts {
|
||||
* Delete a contact
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -210,10 +208,24 @@ export class Contacts {
|
||||
|
||||
const contact = await ContactService.getById(contactId);
|
||||
|
||||
// Fetch project to get language preference
|
||||
const project = await ContactService.getProjectByContactId(contactId);
|
||||
|
||||
// Get contact-level locale (overrides project language)
|
||||
const contactLocale =
|
||||
contact.data &&
|
||||
typeof contact.data === 'object' &&
|
||||
!Array.isArray(contact.data) &&
|
||||
'locale' in contact.data &&
|
||||
typeof contact.data.locale === 'string'
|
||||
? contact.data.locale
|
||||
: null;
|
||||
|
||||
return res.status(200).json({
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
language: contactLocale || project?.language || 'en',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,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'});
|
||||
@@ -288,7 +300,7 @@ export class Contacts {
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to queue import:', error);
|
||||
signale.error('[CONTACTS] Failed to queue import:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue import',
|
||||
});
|
||||
@@ -300,9 +312,10 @@ export class Contacts {
|
||||
* Get import job status
|
||||
*/
|
||||
@Get('import/:jobId')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -310,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'});
|
||||
@@ -318,7 +331,7 @@ export class Contacts {
|
||||
|
||||
return res.status(200).json(status);
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to get import status:', error);
|
||||
signale.error('[CONTACTS] Failed to get import status:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get import status',
|
||||
});
|
||||
@@ -331,10 +344,10 @@ export class Contacts {
|
||||
* Returns information about where the field is used and whether it can be safely deleted
|
||||
*/
|
||||
@Get('fields/:field/usage')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -345,7 +358,7 @@ export class Contacts {
|
||||
const usage = await ContactService.getFieldUsage(auth.projectId!, field);
|
||||
return res.status(200).json(usage);
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to get field usage:', error);
|
||||
signale.error('[CONTACTS] Failed to get field usage:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get field usage',
|
||||
});
|
||||
@@ -358,10 +371,10 @@ export class Contacts {
|
||||
* Only works if the field is not used in any segments or campaigns
|
||||
*/
|
||||
@Delete('fields/:field')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -372,10 +385,144 @@ export class Contacts {
|
||||
const result = await ContactService.deleteField(auth.projectId!, field);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to delete field:', error);
|
||||
signale.error('[CONTACTS] Failed to delete field:', error);
|
||||
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete field',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /contacts/bulk-subscribe
|
||||
* Queue bulk subscribe operation
|
||||
*/
|
||||
@Post('bulk-subscribe')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return res.status(400).json({error: 'contactIds array is required'});
|
||||
}
|
||||
|
||||
// Validate limit
|
||||
if (contactIds.length > 1000) {
|
||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'subscribe');
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Bulk subscribe queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue bulk subscribe:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue bulk subscribe',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /contacts/bulk-unsubscribe
|
||||
* Queue bulk unsubscribe operation
|
||||
*/
|
||||
@Post('bulk-unsubscribe')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return res.status(400).json({error: 'contactIds array is required'});
|
||||
}
|
||||
|
||||
if (contactIds.length > 1000) {
|
||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'unsubscribe');
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Bulk unsubscribe queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue bulk unsubscribe:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue bulk unsubscribe',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /contacts/bulk-delete
|
||||
* Queue bulk delete operation
|
||||
*/
|
||||
@Post('bulk-delete')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return res.status(400).json({error: 'contactIds array is required'});
|
||||
}
|
||||
|
||||
if (contactIds.length > 1000) {
|
||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'delete');
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Bulk delete queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue bulk delete:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue bulk delete',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /contacts/bulk/:jobId
|
||||
* Get bulk action job status
|
||||
*/
|
||||
@Get('bulk/:jobId')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const jobId = req.params.jobId;
|
||||
|
||||
if (!jobId) {
|
||||
return res.status(400).json({error: 'Job ID is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await QueueService.getBulkActionJobStatus(jobId, auth.projectId!);
|
||||
|
||||
if (!status) {
|
||||
return res.status(404).json({error: 'Bulk action job not found'});
|
||||
}
|
||||
|
||||
return res.status(200).json(status);
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to get bulk action status:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get bulk action status',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} 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')
|
||||
@@ -17,23 +16,14 @@ export class Domains {
|
||||
* Get all domains for a project
|
||||
*/
|
||||
@Get('project/:projectId')
|
||||
@Middleware([isAuthenticated])
|
||||
@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);
|
||||
|
||||
@@ -44,10 +34,10 @@ export class Domains {
|
||||
* Add a new domain to a project
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([isAuthenticated])
|
||||
@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);
|
||||
@@ -104,10 +82,10 @@ export class Domains {
|
||||
* Check verification status for a domain
|
||||
*/
|
||||
@Get(':id/verify')
|
||||
@Middleware([isAuthenticated])
|
||||
@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);
|
||||
|
||||
@@ -141,10 +110,10 @@ export class Domains {
|
||||
* Remove a domain from a project
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([isAuthenticated])
|
||||
@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,7 @@
|
||||
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth} from '../middleware/auth.js';
|
||||
import signale from 'signale';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@@ -13,10 +12,10 @@ export class Events {
|
||||
* Track a custom event (can trigger workflows)
|
||||
*/
|
||||
@Post('track')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -33,10 +32,10 @@ export class Events {
|
||||
* List events for the project
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -50,10 +49,10 @@ export class Events {
|
||||
* Get event statistics
|
||||
*/
|
||||
@Get('stats')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -67,10 +66,10 @@ export class Events {
|
||||
* Get events for a specific contact
|
||||
*/
|
||||
@Get('contact/:contactId')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -88,10 +87,10 @@ export class Events {
|
||||
* Get unique event names for the project
|
||||
*/
|
||||
@Get('names')
|
||||
@Middleware([requireAuth])
|
||||
@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!);
|
||||
|
||||
@@ -104,10 +103,10 @@ export class Events {
|
||||
* Returns information about where the event is used and whether it can be safely deleted
|
||||
*/
|
||||
@Get(':eventName/usage')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -118,7 +117,7 @@ export class Events {
|
||||
const usage = await EventService.getEventUsage(auth.projectId!, eventName);
|
||||
return res.status(200).json(usage);
|
||||
} catch (error) {
|
||||
console.error('[EVENTS] Failed to get event usage:', error);
|
||||
signale.error('[EVENTS] Failed to get event usage:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get event usage',
|
||||
});
|
||||
@@ -131,10 +130,10 @@ export class Events {
|
||||
* Only works if the event is not used in any segments or workflows
|
||||
*/
|
||||
@Delete(':eventName')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -145,7 +144,7 @@ export class Events {
|
||||
const result = await EventService.deleteEvent(auth.projectId!, eventName);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[EVENTS] Failed to delete event:', error);
|
||||
signale.error('[EVENTS] Failed to delete event:', error);
|
||||
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete event',
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -40,6 +42,10 @@ export class Github {
|
||||
}
|
||||
const {code} = req.query;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
|
||||
}
|
||||
|
||||
const data = new URLSearchParams({
|
||||
client_id: GITHUB_OAUTH_CLIENT,
|
||||
client_secret: GITHUB_OAUTH_SECRET,
|
||||
@@ -47,26 +53,46 @@ export class Github {
|
||||
redirect_uri: `${API_URI}/oauth/github/callback`,
|
||||
});
|
||||
|
||||
const {access_token, token_type} = await fetch('https://github.com/login/oauth/access_token', {
|
||||
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {'Content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'},
|
||||
body: data,
|
||||
}).then(res => res.json());
|
||||
|
||||
if (!tokenResponse.access_token || !tokenResponse.token_type) {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to authenticate with GitHub');
|
||||
}
|
||||
|
||||
const emails = await fetch(`https://api.github.com/user/emails`, {
|
||||
headers: {Authorization: `${token_type} ${access_token}`},
|
||||
headers: {Authorization: `${tokenResponse.token_type} ${tokenResponse.access_token}`},
|
||||
}).then(res => res.json());
|
||||
|
||||
const email = emails.find((e: {primary: boolean; email: string}) => e.primary).email;
|
||||
if (!Array.isArray(emails) || emails.length === 0) {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve emails from GitHub');
|
||||
}
|
||||
|
||||
let user = await UserService.email(email as string);
|
||||
const primaryEmail = emails.find((e: {primary: boolean; email: string}) => e.primary);
|
||||
|
||||
if (!primaryEmail || !primaryEmail.email || typeof primaryEmail.email !== 'string') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve primary email from GitHub');
|
||||
}
|
||||
|
||||
const email = primaryEmail.email;
|
||||
|
||||
let user = await UserService.email(email);
|
||||
let isNewUser = false;
|
||||
|
||||
if (!user) {
|
||||
// Check if signups are disabled
|
||||
if (DISABLE_SIGNUPS) {
|
||||
throw new BadRequest('New user signups are currently disabled');
|
||||
}
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
type: 'GITHUB_OAUTH',
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
isNewUser = true;
|
||||
|
||||
@@ -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';
|
||||
@@ -35,6 +37,10 @@ export class Google {
|
||||
}
|
||||
const {code} = req.query;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
|
||||
}
|
||||
|
||||
const data = new URLSearchParams({
|
||||
client_id: GOOGLE_OAUTH_CLIENT,
|
||||
client_secret: GOOGLE_OAUTH_SECRET,
|
||||
@@ -43,24 +49,40 @@ export class Google {
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
|
||||
const {access_token} = await fetch('https://oauth2.googleapis.com/token', {
|
||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: {'Content-type': 'application/x-www-form-urlencoded'},
|
||||
body: data,
|
||||
}).then(res => res.json());
|
||||
|
||||
const {email} = await fetch(`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${access_token}`).then(
|
||||
res => res.json(),
|
||||
);
|
||||
if (!tokenResponse.access_token) {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to authenticate with Google');
|
||||
}
|
||||
|
||||
const userInfoResponse = await fetch(
|
||||
`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${tokenResponse.access_token}`,
|
||||
).then(res => res.json());
|
||||
|
||||
if (!userInfoResponse.email || typeof userInfoResponse.email !== 'string') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve email from Google');
|
||||
}
|
||||
|
||||
const email = userInfoResponse.email;
|
||||
|
||||
let user = await UserService.email(email);
|
||||
let isNewUser = false;
|
||||
|
||||
if (!user) {
|
||||
// Check if signups are disabled
|
||||
if (DISABLE_SIGNUPS) {
|
||||
throw new BadRequest('New user signups are currently disabled');
|
||||
}
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
type: 'GOOGLE_OAUTH',
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
isNewUser = true;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import {MembershipSchemas} from '@plunk/shared';
|
||||
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} 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';
|
||||
|
||||
@Controller('projects')
|
||||
@@ -15,23 +16,14 @@ export class Projects {
|
||||
* GET /projects/:id/setup-state
|
||||
*/
|
||||
@Get(':id/setup-state')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
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({
|
||||
@@ -83,51 +75,49 @@ export class Projects {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project security metrics
|
||||
* GET /projects/:id/security
|
||||
*/
|
||||
@Get(':id/security')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Use existing SecurityService
|
||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: metrics,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members of a project
|
||||
* GET /projects/:id/members
|
||||
*/
|
||||
@Get(':id/members')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,11 +127,11 @@ export class Projects {
|
||||
* Body: { email: string, role?: 'ADMIN' | 'MEMBER' }
|
||||
*/
|
||||
@Post(':id/members')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Validate params
|
||||
if (!id) {
|
||||
@@ -157,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({
|
||||
@@ -181,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,
|
||||
@@ -220,10 +178,10 @@ export class Projects {
|
||||
* Body: { role: 'ADMIN' | 'MEMBER' }
|
||||
*/
|
||||
@Patch(':id/members/:userId')
|
||||
@Middleware([requireAuth])
|
||||
@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
|
||||
@@ -243,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({
|
||||
@@ -286,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,
|
||||
@@ -312,10 +231,10 @@ export class Projects {
|
||||
* DELETE /projects/:id/members/:userId
|
||||
*/
|
||||
@Delete(':id/members/:userId')
|
||||
@Middleware([requireAuth])
|
||||
@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
|
||||
@@ -327,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,8 +1,6 @@
|
||||
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} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {SegmentService} from '../services/SegmentService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@@ -13,10 +11,10 @@ export class Segments {
|
||||
* List all segments for the authenticated project
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth])
|
||||
@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!);
|
||||
|
||||
@@ -28,10 +26,10 @@ export class Segments {
|
||||
* Get a specific segment by ID with member count
|
||||
*/
|
||||
@Get(':id')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -48,10 +46,10 @@ export class Segments {
|
||||
* Get contacts that match a segment's filters
|
||||
*/
|
||||
@Get(':id/contacts')
|
||||
@Middleware([requireAuth])
|
||||
@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);
|
||||
@@ -70,24 +68,27 @@ export class Segments {
|
||||
* Create a new segment
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([requireAuth])
|
||||
@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,
|
||||
});
|
||||
|
||||
@@ -99,10 +100,10 @@ export class Segments {
|
||||
* Update a segment
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -129,10 +130,10 @@ export class Segments {
|
||||
* Delete a segment
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([requireAuth])
|
||||
@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,15 +145,65 @@ 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
|
||||
*/
|
||||
@Post(':id/compute')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -169,10 +220,10 @@ export class Segments {
|
||||
* Refresh segment member count
|
||||
*/
|
||||
@Post(':id/refresh')
|
||||
@Middleware([requireAuth])
|
||||
@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,9 +1,7 @@
|
||||
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} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {TemplateService} from '../services/TemplateService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -15,10 +13,10 @@ export class Templates {
|
||||
* List all templates for the authenticated project
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
@@ -34,10 +32,10 @@ export class Templates {
|
||||
* Get a specific template by ID
|
||||
*/
|
||||
@Get(':id')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -54,10 +52,10 @@ export class Templates {
|
||||
* Create a new template
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -98,10 +96,10 @@ export class Templates {
|
||||
* Update a template
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -133,10 +131,10 @@ export class Templates {
|
||||
* Delete a template
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -153,10 +151,10 @@ export class Templates {
|
||||
* Duplicate a template
|
||||
*/
|
||||
@Post(':id/duplicate')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -173,10 +171,10 @@ export class Templates {
|
||||
* Get template usage statistics
|
||||
*/
|
||||
@Get(':id/usage')
|
||||
@Middleware([requireAuth])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getUsage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import {Controller, Middleware, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import multer from 'multer';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth} from '../middleware/auth.js';
|
||||
import signale from 'signale';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import * as S3Service from '../services/S3Service.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
const MAGIC_BYTES: Record<string, Buffer[]> = {
|
||||
'image/jpeg': [Buffer.from([0xff, 0xd8, 0xff])],
|
||||
'image/jpg': [Buffer.from([0xff, 0xd8, 0xff])],
|
||||
'image/png': [Buffer.from([0x89, 0x50, 0x4e, 0x47])],
|
||||
'image/gif': [Buffer.from('GIF87a'), Buffer.from('GIF89a')],
|
||||
'image/webp': [Buffer.from('RIFF')],
|
||||
};
|
||||
|
||||
function validateMagicBytes(buffer: Buffer, mimetype: string): boolean {
|
||||
const signatures = MAGIC_BYTES[mimetype];
|
||||
if (!signatures) return false;
|
||||
return signatures.some(sig => buffer.subarray(0, sig.length).equals(sig));
|
||||
}
|
||||
|
||||
// Configure multer for file uploads (memory storage)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
@@ -14,13 +27,12 @@ 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'];
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed (JPEG, PNG, GIF, WebP, SVG)'));
|
||||
cb(new Error('Only image files are allowed (JPEG, PNG, GIF, WebP)'));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -32,10 +44,10 @@ export class Uploads {
|
||||
* Upload an image file to S3/Minio
|
||||
*/
|
||||
@Post('image')
|
||||
@Middleware([requireAuth, upload.single('image')])
|
||||
@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()) {
|
||||
@@ -50,6 +62,12 @@ export class Uploads {
|
||||
});
|
||||
}
|
||||
|
||||
if (!validateMagicBytes(req.file.buffer, req.file.mimetype)) {
|
||||
return res.status(400).json({
|
||||
error: 'File contents do not match the declared image type',
|
||||
});
|
||||
}
|
||||
|
||||
// Upload file to S3/Minio
|
||||
const result = await S3Service.uploadFile({
|
||||
file: req.file.buffer,
|
||||
@@ -66,7 +84,7 @@ export class Uploads {
|
||||
size: req.file.size,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[UPLOADS] Failed to upload image:', error);
|
||||
signale.error('[UPLOADS] Failed to upload image:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to upload image',
|
||||
});
|
||||
|
||||
+117
-181
@@ -1,16 +1,16 @@
|
||||
import {randomBytes} from 'node:crypto';
|
||||
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
|
||||
import {BillingLimitSchemas, ProjectSchemas} from '@plunk/shared';
|
||||
import {BillingLimitSchemas, ProjectSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {NotAuthenticated, NotFound} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {isAuthenticated} from '../middleware/auth.js';
|
||||
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
|
||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
@@ -20,10 +20,10 @@ import signale from 'signale';
|
||||
@Controller('users')
|
||||
export class Users {
|
||||
@Get('@me')
|
||||
@Middleware([isAuthenticated])
|
||||
@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();
|
||||
@@ -39,10 +39,10 @@ export class Users {
|
||||
}
|
||||
|
||||
@Get('@me/projects')
|
||||
@Middleware([isAuthenticated])
|
||||
@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();
|
||||
@@ -54,15 +54,25 @@ export class Users {
|
||||
}
|
||||
|
||||
@Post('@me/projects')
|
||||
@Middleware([isAuthenticated])
|
||||
@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();
|
||||
}
|
||||
|
||||
// Check if user is a member of any disabled project
|
||||
const {hasDisabledProject, disabledProjectNames} = await SecurityService.userHasDisabledProject(auth.userId);
|
||||
if (hasDisabledProject) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
`You cannot create new projects while you are a member of disabled projects: ${disabledProjectNames.join(', ')}. Please contact support to resolve security violations.`,
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
const {name} = ProjectSchemas.create.parse(req.body);
|
||||
|
||||
// Generate unique API keys
|
||||
@@ -91,27 +101,15 @@ export class Users {
|
||||
}
|
||||
|
||||
@Patch('@me/projects/:id')
|
||||
@Middleware([isAuthenticated])
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
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({
|
||||
@@ -123,26 +121,14 @@ export class Users {
|
||||
}
|
||||
|
||||
@Post('@me/projects/:id/regenerate-keys')
|
||||
@Middleware([isAuthenticated])
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
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')}`;
|
||||
@@ -175,31 +161,20 @@ export class Users {
|
||||
}
|
||||
|
||||
@Post('@me/projects/:id/checkout')
|
||||
@Middleware([isAuthenticated])
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const {currency} = req.query;
|
||||
|
||||
// Check if billing is enabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
return res.status(404).json({error: 'Billing is not enabled'});
|
||||
}
|
||||
|
||||
// Verify user has 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({
|
||||
@@ -235,6 +210,17 @@ export class Users {
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const billingCycleAnchor = Math.floor(nextMonth.getTime() / 1000);
|
||||
|
||||
// Validate currency if provided
|
||||
let checkoutCurrency: string | undefined;
|
||||
if (currency && typeof currency === 'string') {
|
||||
const validCurrencies = ['usd', 'eur', 'gbp'];
|
||||
if (validCurrencies.includes(currency.toLowerCase())) {
|
||||
checkoutCurrency = currency.toLowerCase();
|
||||
} else {
|
||||
return res.status(400).json({error: 'Invalid currency. Supported: USD, EUR, GBP'});
|
||||
}
|
||||
}
|
||||
|
||||
// Create checkout session
|
||||
// Note: proration_behavior cannot be set when one-time prices are included
|
||||
// The billing_cycle_anchor alone ensures the subscription is anchored to the 1st of the month
|
||||
@@ -243,6 +229,7 @@ export class Users {
|
||||
customer: project.customer ?? undefined, // Use existing customer if available
|
||||
client_reference_id: project.id, // Store project ID for webhook
|
||||
line_items: lineItems,
|
||||
...(checkoutCurrency && {currency: checkoutCurrency}),
|
||||
subscription_data: {
|
||||
billing_cycle_anchor: billingCycleAnchor,
|
||||
},
|
||||
@@ -254,31 +241,19 @@ export class Users {
|
||||
}
|
||||
|
||||
@Post('@me/projects/:id/billing-portal')
|
||||
@Middleware([isAuthenticated])
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
return res.status(404).json({error: 'Billing is not enabled'});
|
||||
}
|
||||
|
||||
// Verify user has 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({
|
||||
@@ -304,11 +279,11 @@ export class Users {
|
||||
}
|
||||
|
||||
@Get('@me/projects/:id/billing-limits')
|
||||
@Middleware([isAuthenticated])
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -319,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);
|
||||
@@ -337,11 +303,11 @@ export class Users {
|
||||
}
|
||||
|
||||
@Put('@me/projects/:id/billing-limits')
|
||||
@Middleware([isAuthenticated])
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -354,25 +320,17 @@ 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to update billing limits');
|
||||
}
|
||||
|
||||
// Get the project
|
||||
// Get the project with current limits
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
select: {
|
||||
subscription: true,
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
billingLimitInbound: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -392,11 +350,22 @@ export class Users {
|
||||
billingLimitWorkflows: data.workflows,
|
||||
billingLimitCampaigns: data.campaigns,
|
||||
billingLimitTransactional: data.transactional,
|
||||
billingLimitInbound: data.inbound,
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cache so new limits take effect immediately
|
||||
await BillingLimitService.invalidateCache(id);
|
||||
// Clear notification cache keys for limits that changed
|
||||
// This allows new warning/limit emails to be sent when the new limits are reached
|
||||
await BillingLimitService.clearNotificationCacheForChangedLimits(
|
||||
id,
|
||||
{
|
||||
workflows: project.billingLimitWorkflows,
|
||||
campaigns: project.billingLimitCampaigns,
|
||||
transactional: project.billingLimitTransactional,
|
||||
inbound: project.billingLimitInbound,
|
||||
},
|
||||
data,
|
||||
);
|
||||
|
||||
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
||||
|
||||
@@ -404,11 +373,11 @@ export class Users {
|
||||
}
|
||||
|
||||
@Get('@me/projects/:id/billing-consumption')
|
||||
@Middleware([isAuthenticated])
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
@@ -424,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},
|
||||
@@ -530,11 +490,11 @@ export class Users {
|
||||
}
|
||||
|
||||
@Get('@me/projects/:id/billing-invoices')
|
||||
@Middleware([isAuthenticated])
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
@@ -550,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({
|
||||
@@ -618,11 +569,11 @@ export class Users {
|
||||
}
|
||||
|
||||
@Get('@me/projects/:id/security')
|
||||
@Middleware([isAuthenticated])
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -633,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);
|
||||
@@ -651,11 +593,11 @@ export class Users {
|
||||
}
|
||||
|
||||
@Post('@me/projects/:id/reset')
|
||||
@Middleware([isAuthenticated])
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -666,18 +608,16 @@ 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to reset it');
|
||||
// Check if project is disabled - block reset operation
|
||||
const isDisabled = await SecurityService.isProjectDisabled(id);
|
||||
if (isDisabled) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Cannot reset a disabled project. Please contact support to resolve security violations before making changes.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all project data in a transaction
|
||||
@@ -727,11 +667,11 @@ export class Users {
|
||||
}
|
||||
|
||||
@Delete('@me/projects/:id')
|
||||
@Middleware([isAuthenticated])
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -742,29 +682,16 @@ 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound(
|
||||
'Project not found or you do not have permission to delete it. Only project owners and admins can delete projects.',
|
||||
);
|
||||
}
|
||||
|
||||
// Get project to check for active subscription
|
||||
// Get project to check for active subscription and disabled status
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
select: {
|
||||
name: true,
|
||||
subscription: true,
|
||||
customer: true,
|
||||
disabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -772,6 +699,15 @@ export class Users {
|
||||
throw new NotFound('Project not found');
|
||||
}
|
||||
|
||||
// Check if project is disabled - block delete operation
|
||||
if (project.disabled) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Cannot delete a disabled project. Please contact support to resolve security violations.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
// If project has an active subscription, cancel it first
|
||||
if (STRIPE_ENABLED && stripe && project.subscription) {
|
||||
try {
|
||||
|
||||
@@ -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 or sns.<region>.amazonaws.eu
|
||||
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.(com|eu)$/;
|
||||
if (parsedURL.protocol !== 'https:' || !SNS_HOST_RE.test(parsedURL.hostname)) {
|
||||
signale.warn(`SNS SubscriptionConfirmation rejected — disallowed SubscribeURL host: ${parsedURL.hostname}`);
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
// Automatically confirm the subscription
|
||||
try {
|
||||
const confirmResponse = await fetch(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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import {WorkflowExecutionStatus} from '@plunk/db';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth} from '../middleware/auth.js';
|
||||
import signale from 'signale';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {WorkflowService} from '../services/WorkflowService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@@ -14,10 +13,10 @@ export class Workflows {
|
||||
* List all workflows for the authenticated project
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
@@ -34,10 +33,10 @@ export class Workflows {
|
||||
* NOTE: This must be defined BEFORE the :id route to avoid conflicts
|
||||
*/
|
||||
@Get('fields')
|
||||
@Middleware([requireAuth])
|
||||
@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 {
|
||||
@@ -45,7 +44,7 @@ export class Workflows {
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[WORKFLOWS] Failed to get available fields:', error);
|
||||
signale.error('[WORKFLOWS] Failed to get available fields:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
||||
});
|
||||
@@ -57,10 +56,10 @@ export class Workflows {
|
||||
* Get a specific workflow with all steps and transitions
|
||||
*/
|
||||
@Get(':id')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -77,10 +76,10 @@ export class Workflows {
|
||||
* Create a new workflow
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -107,10 +106,10 @@ export class Workflows {
|
||||
* Update a workflow
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -135,10 +134,10 @@ export class Workflows {
|
||||
* Delete a workflow
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
@@ -155,10 +154,10 @@ export class Workflows {
|
||||
* Add a step to a workflow
|
||||
*/
|
||||
@Post(':id/steps')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -187,10 +186,10 @@ export class Workflows {
|
||||
* Update a workflow step
|
||||
*/
|
||||
@Patch(':id/steps/:stepId')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
@@ -214,10 +213,10 @@ export class Workflows {
|
||||
* Delete a workflow step
|
||||
*/
|
||||
@Delete(':id/steps/:stepId')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -235,10 +234,10 @@ export class Workflows {
|
||||
* Create a transition between steps
|
||||
*/
|
||||
@Post(':id/transitions')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -265,10 +264,10 @@ export class Workflows {
|
||||
* Delete a transition
|
||||
*/
|
||||
@Delete(':id/transitions/:transitionId')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -286,10 +285,10 @@ export class Workflows {
|
||||
* Start a workflow execution for a contact
|
||||
*/
|
||||
@Post(':id/executions')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -311,10 +310,10 @@ export class Workflows {
|
||||
* List executions for a workflow
|
||||
*/
|
||||
@Get(':id/executions')
|
||||
@Middleware([requireAuth])
|
||||
@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);
|
||||
@@ -334,10 +333,10 @@ export class Workflows {
|
||||
* Get a specific execution with details
|
||||
*/
|
||||
@Get(':id/executions/:executionId')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -355,10 +354,10 @@ export class Workflows {
|
||||
* Cancel a workflow execution
|
||||
*/
|
||||
@Delete(':id/executions/:executionId')
|
||||
@Middleware([requireAuth])
|
||||
@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;
|
||||
|
||||
@@ -376,10 +375,10 @@ export class Workflows {
|
||||
* Cancel all active executions for a workflow
|
||||
*/
|
||||
@Post(':id/executions/cancel-all')
|
||||
@Middleware([requireAuth])
|
||||
@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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum ErrorCode {
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
PROJECT_ACCESS_DENIED = 'PROJECT_ACCESS_DENIED',
|
||||
PROJECT_DISABLED = 'PROJECT_DISABLED',
|
||||
EMAIL_VERIFICATION_REQUIRED = 'EMAIL_VERIFICATION_REQUIRED',
|
||||
|
||||
// Resource Errors (404-409)
|
||||
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
|
||||
@@ -73,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: 'test@example.com',
|
||||
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: 'test@example.com',
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {ContactService} from '../../services/ContactService.js';
|
||||
|
||||
/**
|
||||
* Tests for Contact Import Processor - Subscription Status Preservation
|
||||
* Verifies that CSV imports preserve subscription status correctly
|
||||
*/
|
||||
describe('Contact Import - Subscription Status Preservation', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
describe('Existing contacts', () => {
|
||||
it('should NOT change subscription status when CSV has no subscribed column for subscribed contact', async () => {
|
||||
// Create a subscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
email: 'existing-subscribed@example.com',
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(contact.subscribed).toBe(true);
|
||||
|
||||
// Simulate import without subscribed column (undefined)
|
||||
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, undefined);
|
||||
|
||||
// Verify subscription status unchanged
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updated?.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT re-subscribe unsubscribed contact when CSV has no subscribed column', async () => {
|
||||
// Create an unsubscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'existing-unsubscribed@example.com',
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(contact.subscribed).toBe(false);
|
||||
|
||||
// Simulate import without subscribed column (undefined)
|
||||
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, undefined);
|
||||
|
||||
// Verify subscription status unchanged (should NOT be re-subscribed)
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updated?.subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should update subscription status when CSV explicitly has subscribed=true', async () => {
|
||||
// Create an unsubscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'import-resubscribe@example.com',
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(contact.subscribed).toBe(false);
|
||||
|
||||
// Simulate import with explicit subscribed=true
|
||||
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, true);
|
||||
|
||||
// Verify subscription status changed
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updated?.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should update subscription status when CSV explicitly has subscribed=false', async () => {
|
||||
// Create a subscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
email: 'import-unsubscribe@example.com',
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(contact.subscribed).toBe(true);
|
||||
|
||||
// Simulate import with explicit subscribed=false
|
||||
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, false);
|
||||
|
||||
// Verify subscription status changed
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updated?.subscribed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('New contacts', () => {
|
||||
it('should create new contact as subscribed when CSV has no subscribed column', async () => {
|
||||
const newEmail = 'new-import-default@example.com';
|
||||
|
||||
// Simulate import without subscribed column (undefined)
|
||||
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, undefined);
|
||||
|
||||
// New contacts should default to subscribed=true
|
||||
expect(contact.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should create new contact as subscribed when CSV explicitly has subscribed=true', async () => {
|
||||
const newEmail = 'new-import-explicit-sub@example.com';
|
||||
|
||||
// Simulate import with explicit subscribed=true
|
||||
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, true);
|
||||
|
||||
expect(contact.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should create new contact as unsubscribed when CSV explicitly has subscribed=false', async () => {
|
||||
const newEmail = 'new-import-explicit-unsub@example.com';
|
||||
|
||||
// Simulate import with explicit subscribed=false
|
||||
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, false);
|
||||
|
||||
expect(contact.subscribed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSV parsing logic', () => {
|
||||
it('should parse "true" string as boolean true', () => {
|
||||
const subscribedValue = 'true';
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
|
||||
expect(subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse "1" string as boolean true', () => {
|
||||
const subscribedValue = '1';
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
|
||||
expect(subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse "yes" string as boolean true', () => {
|
||||
const subscribedValue = 'yes';
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
|
||||
expect(subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse "false" string as boolean false', () => {
|
||||
const subscribedValue = 'false';
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
|
||||
expect(subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse "0" string as boolean false', () => {
|
||||
const subscribedValue = '0';
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
|
||||
expect(subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse "no" string as boolean false', () => {
|
||||
const subscribedValue = 'no';
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
|
||||
expect(subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty string as undefined', () => {
|
||||
const subscribedValue = '';
|
||||
let subscribed: boolean | undefined;
|
||||
|
||||
if (subscribedValue !== undefined && subscribedValue !== '') {
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
}
|
||||
|
||||
expect(subscribed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle undefined as undefined', () => {
|
||||
const subscribedValue = undefined;
|
||||
let subscribed: boolean | undefined;
|
||||
|
||||
if (subscribedValue !== undefined && subscribedValue !== '') {
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
}
|
||||
|
||||
expect(subscribed).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data preservation', () => {
|
||||
it('should preserve existing contact data while updating subscription', async () => {
|
||||
// Create contact with existing data
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'preserve-data@example.com',
|
||||
data: {
|
||||
firstName: 'Original',
|
||||
lastName: 'Name',
|
||||
plan: 'pro',
|
||||
},
|
||||
});
|
||||
|
||||
// Update only subscription via import
|
||||
await ContactService.upsert(projectId, contact.email, {}, true);
|
||||
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
// Subscription should be updated
|
||||
expect(updated?.subscribed).toBe(true);
|
||||
|
||||
// Original data should be preserved
|
||||
const data = updated?.data as Record<string, unknown>;
|
||||
expect(data?.firstName).toBe('Original');
|
||||
expect(data?.lastName).toBe('Name');
|
||||
expect(data?.plan).toBe('pro');
|
||||
});
|
||||
|
||||
it('should merge new data while preserving subscription', async () => {
|
||||
// Create contact with existing data
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'merge-data@example.com',
|
||||
data: {
|
||||
firstName: 'John',
|
||||
plan: 'pro',
|
||||
},
|
||||
});
|
||||
|
||||
// Import new data without changing subscription
|
||||
await ContactService.upsert(
|
||||
projectId,
|
||||
contact.email,
|
||||
{
|
||||
lastName: 'Doe',
|
||||
company: 'Acme Inc',
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
// Subscription should be unchanged
|
||||
expect(updated?.subscribed).toBe(false);
|
||||
|
||||
// Data should be merged
|
||||
const data = updated?.data as Record<string, unknown>;
|
||||
expect(data?.firstName).toBe('John'); // Preserved
|
||||
expect(data?.plan).toBe('pro'); // Preserved
|
||||
expect(data?.lastName).toBe('Doe'); // New
|
||||
expect(data?.company).toBe('Acme Inc'); // New
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Background Job: Bulk Contact Action Processor
|
||||
* Processes bulk subscribe, unsubscribe, and delete operations
|
||||
*/
|
||||
|
||||
import type {BulkContactActionJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {bulkContactQueue} from '../services/QueueService.js';
|
||||
|
||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||
|
||||
interface BulkActionResult {
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||
totalRequested: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
errors: {contactId: string; email: string; error: string}[];
|
||||
}
|
||||
|
||||
export function createBulkContactWorker() {
|
||||
const worker = new Worker<BulkContactActionJobData>(
|
||||
bulkContactQueue.name,
|
||||
async (job: Job<BulkContactActionJobData>) => {
|
||||
const {projectId, contactIds, operation} = job.data;
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
|
||||
);
|
||||
|
||||
const result: BulkActionResult = {
|
||||
operation,
|
||||
totalRequested: contactIds.length,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Process contacts in batches
|
||||
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
|
||||
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
|
||||
|
||||
try {
|
||||
let batchResult: {updated?: number; deleted?: number};
|
||||
|
||||
switch (operation) {
|
||||
case 'subscribe':
|
||||
batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
|
||||
result.successCount += batchResult.updated || 0;
|
||||
break;
|
||||
case 'unsubscribe':
|
||||
batchResult = await ContactService.bulkUnsubscribe(projectId, batchIds);
|
||||
result.successCount += batchResult.updated || 0;
|
||||
break;
|
||||
case 'delete':
|
||||
batchResult = await ContactService.bulkDelete(projectId, batchIds);
|
||||
result.successCount += batchResult.deleted || 0;
|
||||
break;
|
||||
}
|
||||
|
||||
// If some contacts in batch weren't processed, track them as failures
|
||||
const processedCount = batchResult.updated || batchResult.deleted || 0;
|
||||
const failedCount = batchIds.length - processedCount;
|
||||
if (failedCount > 0) {
|
||||
result.failureCount += failedCount;
|
||||
// Note: We don't have individual contact details for batch failures
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[BULK-CONTACT-PROCESSOR] Batch failed:`, error);
|
||||
result.failureCount += batchIds.length;
|
||||
result.errors.push({
|
||||
contactId: 'batch',
|
||||
email: '',
|
||||
error: error instanceof Error ? error.message : 'Batch processing failed',
|
||||
});
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
|
||||
await job.updateProgress(progress);
|
||||
}
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
signale.error(`[BULK-CONTACT-PROCESSOR] Failed to process ${operation}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: bulkContactQueue.opts.connection,
|
||||
concurrency: 3, // Process max 3 bulk operations concurrently
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.info(`[BULK-CONTACT-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
signale.error(`[BULK-CONTACT-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
signale.error('[BULK-CONTACT-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -3,10 +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>(
|
||||
@@ -14,11 +16,11 @@ export function createCampaignWorker() {
|
||||
async (job: Job<CampaignBatchJobData>) => {
|
||||
const {campaignId, batchNumber, offset, limit, cursor} = job.data;
|
||||
|
||||
console.log(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
|
||||
signale.info(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
|
||||
|
||||
await CampaignService.processBatch(campaignId, batchNumber, offset, limit, cursor);
|
||||
|
||||
console.log(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
|
||||
signale.info(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
|
||||
},
|
||||
{
|
||||
connection: campaignQueue.opts.connection,
|
||||
@@ -27,15 +29,15 @@ export function createCampaignWorker() {
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
|
||||
signale.info(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
signale.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
|
||||
signale.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -3,18 +3,51 @@
|
||||
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
|
||||
*/
|
||||
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import type {SendEmailJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import 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 {sendRawEmail} from '../services/SESService.js';
|
||||
import {DASHBOARD_URI} from '../app/constants.js';
|
||||
import {emailQueue} from '../services/QueueService.js';
|
||||
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
|
||||
|
||||
export function createEmailWorker() {
|
||||
/**
|
||||
* Determine the email sending rate limit (emails per second)
|
||||
* Priority: ENV variable > AWS SES quota > Safe default (14)
|
||||
*/
|
||||
async function getEmailRateLimit(): Promise<number> {
|
||||
const DEFAULT_RATE_LIMIT = 14; // AWS SES sandbox limit - safe default
|
||||
|
||||
// If env variable is set, use it (override)
|
||||
if (EMAIL_RATE_LIMIT_PER_SECOND !== undefined) {
|
||||
signale.info(`[EMAIL-PROCESSOR] Using rate limit from environment: ${EMAIL_RATE_LIMIT_PER_SECOND} emails/second`);
|
||||
return EMAIL_RATE_LIMIT_PER_SECOND;
|
||||
}
|
||||
|
||||
// Try to fetch from AWS SES
|
||||
signale.info('[EMAIL-PROCESSOR] Fetching rate limit from AWS SES...');
|
||||
const quota = await getSendingQuota();
|
||||
|
||||
if (quota) {
|
||||
signale.info(
|
||||
`[EMAIL-PROCESSOR] AWS SES quota: ${quota.maxSendRate} emails/second (${quota.sentLast24Hours}/${quota.max24HourSend} emails sent today)`,
|
||||
);
|
||||
return quota.maxSendRate;
|
||||
}
|
||||
|
||||
// Fallback to safe default
|
||||
signale.warn(`[EMAIL-PROCESSOR] Failed to fetch AWS quota, using safe default: ${DEFAULT_RATE_LIMIT} emails/second`);
|
||||
return DEFAULT_RATE_LIMIT;
|
||||
}
|
||||
|
||||
export async function createEmailWorker() {
|
||||
// Fetch the rate limit (from env, AWS, or default)
|
||||
const rateLimit = await getEmailRateLimit();
|
||||
const worker = new Worker<SendEmailJobData>(
|
||||
emailQueue.name,
|
||||
async (job: Job<SendEmailJobData>) => {
|
||||
@@ -38,7 +71,7 @@ export function createEmailWorker() {
|
||||
|
||||
// Check if project is disabled
|
||||
if (email.project.disabled) {
|
||||
console.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
|
||||
signale.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
|
||||
await prisma.email.update({
|
||||
where: {id: emailId},
|
||||
data: {
|
||||
@@ -84,10 +117,25 @@ export 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);
|
||||
@@ -104,6 +152,7 @@ export function createEmailWorker() {
|
||||
html: compiledHtml,
|
||||
},
|
||||
reply: email.replyTo || undefined,
|
||||
headers: publicHeaders,
|
||||
tracking: shouldTrack,
|
||||
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
|
||||
});
|
||||
@@ -138,8 +187,60 @@ export function createEmailWorker() {
|
||||
sourceType: email.sourceType,
|
||||
sentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// If this email belongs to a campaign, check if all campaign emails have been sent
|
||||
if (email.campaignId) {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: email.campaignId},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
totalRecipients: true,
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Only check if campaign is still in SENDING status
|
||||
if (campaign && campaign.status === CampaignStatus.SENDING) {
|
||||
// Count how many emails have been sent for this campaign
|
||||
const sentCount = await prisma.email.count({
|
||||
where: {
|
||||
campaignId: email.campaignId,
|
||||
sentAt: {not: null},
|
||||
},
|
||||
});
|
||||
|
||||
// If all emails have been sent, mark campaign as SENT
|
||||
if (sentCount >= campaign.totalRecipients) {
|
||||
await prisma.campaign.update({
|
||||
where: {id: email.campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.SENT,
|
||||
sentCount,
|
||||
},
|
||||
});
|
||||
|
||||
signale.success(
|
||||
`[EMAIL-PROCESSOR] Campaign ${campaign.name} completed: ${sentCount}/${campaign.totalRecipients} emails sent`,
|
||||
);
|
||||
|
||||
// Send notification about campaign send completed
|
||||
const {NtfyService} = await import('../services/NtfyService.js');
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
campaign.name,
|
||||
campaign.project.name,
|
||||
campaign.projectId,
|
||||
campaign.totalRecipients,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
|
||||
signale.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
|
||||
|
||||
// Mark as failed
|
||||
await prisma.email.update({
|
||||
@@ -157,22 +258,22 @@ export function createEmailWorker() {
|
||||
connection: emailQueue.opts.connection,
|
||||
concurrency: 10, // Process up to 10 emails concurrently
|
||||
limiter: {
|
||||
max: 25, // Max 25 emails per second
|
||||
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
|
||||
duration: 1000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
|
||||
signale.info(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
signale.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[EMAIL-PROCESSOR] Worker error:', err);
|
||||
signale.error('[EMAIL-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
* Processes CSV contact imports with validation and batch processing
|
||||
*/
|
||||
|
||||
import type {ContactImportJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import {parse} from 'csv-parse/sync';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {type ContactImportJobData, importQueue} from '../services/QueueService.js';
|
||||
import {importQueue} from '../services/QueueService.js';
|
||||
|
||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||
|
||||
@@ -28,7 +30,7 @@ export function createImportWorker() {
|
||||
async (job: Job<ContactImportJobData>) => {
|
||||
const {projectId, csvData, filename} = job.data;
|
||||
|
||||
console.log(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
|
||||
signale.info(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
|
||||
|
||||
// Fetch project information for notifications
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -51,9 +53,9 @@ export function createImportWorker() {
|
||||
// Decode base64 CSV data
|
||||
const csvContent = Buffer.from(csvData, 'base64').toString('utf-8');
|
||||
|
||||
// Parse CSV
|
||||
// Parse CSV with column header normalization
|
||||
const records = parse(csvContent, {
|
||||
columns: true, // Use first row as header
|
||||
columns: (header: string[]) => header.map(h => h.toLowerCase()), // Normalize headers to lowercase
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
relax_column_count: true, // Allow rows with different column counts
|
||||
@@ -66,15 +68,15 @@ export function createImportWorker() {
|
||||
throw new Error('CSV file is empty');
|
||||
}
|
||||
|
||||
console.log(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
|
||||
signale.info(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
|
||||
|
||||
// Notify that import has started
|
||||
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
|
||||
|
||||
// Validate that 'email' column exists
|
||||
// Validate that 'email' column exists (case-insensitive)
|
||||
const firstRecord = records[0];
|
||||
if (firstRecord && typeof firstRecord === 'object' && !('email' in firstRecord)) {
|
||||
throw new Error('CSV must have an "email" column');
|
||||
throw new Error('CSV must have an "email" column (case-insensitive)');
|
||||
}
|
||||
|
||||
// Process contacts in batches
|
||||
@@ -109,7 +111,7 @@ export function createImportWorker() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract subscribed field if present
|
||||
// Extract subscribed field if present (case-insensitive)
|
||||
const subscribedValue = record.subscribed;
|
||||
let subscribed: boolean | undefined;
|
||||
|
||||
@@ -127,8 +129,10 @@ export function createImportWorker() {
|
||||
const existingContact = await ContactService.findByEmail(projectId, email);
|
||||
const isUpdate = !!existingContact;
|
||||
|
||||
// Upsert contact with subscribed value from CSV if provided, otherwise default to true
|
||||
await ContactService.upsert(projectId, email, data, subscribed ?? true);
|
||||
// Upsert contact with subscribed value from CSV if provided
|
||||
// For new contacts, ContactService.upsert defaults to true
|
||||
// For existing contacts, only update if explicitly provided in CSV
|
||||
await ContactService.upsert(projectId, email, data, subscribed);
|
||||
|
||||
result.successCount++;
|
||||
if (isUpdate) {
|
||||
@@ -151,7 +155,7 @@ export function createImportWorker() {
|
||||
await job.updateProgress(progress);
|
||||
}
|
||||
|
||||
console.log(
|
||||
signale.info(
|
||||
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
|
||||
);
|
||||
|
||||
@@ -168,7 +172,7 @@ export function createImportWorker() {
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
|
||||
signale.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
|
||||
|
||||
// Notify that import has failed
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@@ -191,15 +195,15 @@ export function createImportWorker() {
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
|
||||
signale.info(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
signale.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[IMPORT-PROCESSOR] Worker error:', err);
|
||||
signale.error('[IMPORT-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
|
||||
@@ -4,11 +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>(
|
||||
@@ -16,7 +18,7 @@ export function createScheduledCampaignWorker() {
|
||||
async (job: Job<ScheduledCampaignJobData>) => {
|
||||
const {campaignId} = job.data;
|
||||
|
||||
console.log(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
|
||||
signale.info(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
|
||||
|
||||
// Get campaign with project
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
@@ -29,13 +31,13 @@ export function createScheduledCampaignWorker() {
|
||||
});
|
||||
|
||||
if (!campaign) {
|
||||
console.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
|
||||
signale.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if project is disabled
|
||||
if (campaign.project.disabled) {
|
||||
console.warn(
|
||||
signale.warn(
|
||||
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
|
||||
);
|
||||
await prisma.campaign.update({
|
||||
@@ -47,7 +49,7 @@ export function createScheduledCampaignWorker() {
|
||||
|
||||
// Verify campaign is still in SCHEDULED status
|
||||
if (campaign.status !== CampaignStatus.SCHEDULED) {
|
||||
console.warn(
|
||||
signale.warn(
|
||||
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
|
||||
);
|
||||
return;
|
||||
@@ -56,7 +58,7 @@ export function createScheduledCampaignWorker() {
|
||||
// Start sending the campaign
|
||||
await CampaignService.startSending(campaign.projectId, campaignId);
|
||||
|
||||
console.log(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
|
||||
signale.info(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
|
||||
},
|
||||
{
|
||||
connection: scheduledQueue.opts.connection,
|
||||
@@ -65,15 +67,15 @@ export function createScheduledCampaignWorker() {
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
|
||||
signale.info(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
signale.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[SCHEDULED-PROCESSOR] Worker error:', err);
|
||||
signale.error('[SCHEDULED-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js';
|
||||
import {createBulkContactWorker} from './bulk-contact-processor.js';
|
||||
import {createCampaignWorker} from './campaign-processor.js';
|
||||
import {createDomainVerificationWorker} from './domain-verification-processor.js';
|
||||
import {createEmailWorker} from './email-processor.js';
|
||||
@@ -25,7 +26,7 @@ async function startWorkers() {
|
||||
|
||||
try {
|
||||
// Start email worker
|
||||
const emailWorker = createEmailWorker();
|
||||
const emailWorker = await createEmailWorker();
|
||||
workers.push({name: 'email', worker: emailWorker});
|
||||
signale.success('[WORKER] Email worker started');
|
||||
|
||||
@@ -49,6 +50,11 @@ async function startWorkers() {
|
||||
workers.push({name: 'import', worker: importWorker});
|
||||
signale.success('[WORKER] Import worker started');
|
||||
|
||||
// Start bulk contact action worker
|
||||
const bulkContactWorker = createBulkContactWorker();
|
||||
workers.push({name: 'bulk-contact-actions', worker: bulkContactWorker});
|
||||
signale.success('[WORKER] Bulk contact action worker started');
|
||||
|
||||
// Start segment count worker
|
||||
const segmentCountWorker = createSegmentCountWorker();
|
||||
workers.push({name: 'segment-count', worker: segmentCountWorker});
|
||||
|
||||
@@ -3,9 +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() {
|
||||
@@ -33,15 +35,15 @@ export function createWorkflowWorker() {
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
|
||||
signale.info(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
signale.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[WORKFLOW-PROCESSOR] Worker error:', err);
|
||||
signale.error('[WORKFLOW-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+98
-126
@@ -2,15 +2,13 @@ import dayjs from 'dayjs';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import jsonwebtoken from 'jsonwebtoken';
|
||||
|
||||
import {JWT_SECRET} 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
|
||||
@@ -65,7 +63,7 @@ export const jwt = {
|
||||
* @param request The express request object
|
||||
*/
|
||||
export function parseJwt(request: Request): string {
|
||||
const token: string | undefined = request.cookies.token;
|
||||
const token: string | undefined = request.cookies.next_token;
|
||||
|
||||
if (!token) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set auth response with project ID
|
||||
res.locals.auth = {
|
||||
type: 'jwt',
|
||||
userId,
|
||||
projectId,
|
||||
} as AuthResponse;
|
||||
|
||||
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(
|
||||
@@ -189,6 +121,12 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
|
||||
);
|
||||
}
|
||||
|
||||
// Set auth response with project ID (before disabled check so it's available for logging)
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
};
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project.disabled) {
|
||||
const method = req.method.toUpperCase();
|
||||
@@ -203,12 +141,6 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
|
||||
}
|
||||
}
|
||||
|
||||
// Set auth response with project ID
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
} as AuthResponse;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -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(
|
||||
@@ -258,6 +190,12 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
|
||||
);
|
||||
}
|
||||
|
||||
// Set auth response with project ID (before disabled check so it's available for logging)
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
};
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project.disabled) {
|
||||
const method = req.method.toUpperCase();
|
||||
@@ -272,12 +210,6 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
|
||||
}
|
||||
}
|
||||
|
||||
// Set auth response with project ID
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
} as AuthResponse;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -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(
|
||||
@@ -325,6 +257,12 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
);
|
||||
}
|
||||
|
||||
// Set auth response with project ID (before disabled check so it's available for logging)
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
} as AuthResponse;
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project.disabled) {
|
||||
const method = req.method.toUpperCase();
|
||||
@@ -339,12 +277,6 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
}
|
||||
}
|
||||
|
||||
// Set auth response with project ID
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
} as AuthResponse;
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -360,24 +292,21 @@ 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) {
|
||||
throw new HttpException(403, 'You do not have access to this project', ErrorCode.PROJECT_ACCESS_DENIED);
|
||||
}
|
||||
|
||||
// Set auth response with project ID (before disabled check so it's available for logging)
|
||||
res.locals.auth = {
|
||||
type: 'jwt',
|
||||
userId,
|
||||
projectId,
|
||||
};
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project?.disabled) {
|
||||
const method = req.method.toUpperCase();
|
||||
@@ -392,12 +321,55 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
}
|
||||
}
|
||||
|
||||
// Set auth response with project ID
|
||||
res.locals.auth = {
|
||||
type: 'jwt',
|
||||
userId,
|
||||
projectId,
|
||||
} as AuthResponse;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require email verification
|
||||
* Must be used AFTER isAuthenticated or requireProjectAccess
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (auth.type === 'apiKey') {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const user = await UserService.id(auth.userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
// If platform email verification is disabled, skip check
|
||||
if (!PLUNK_ENABLED) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// OAuth users are always considered verified
|
||||
if (user.type !== 'PASSWORD') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// PASSWORD users must verify email
|
||||
if (!user.emailVerified) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Please verify your email address to access this resource',
|
||||
ErrorCode.EMAIL_VERIFICATION_REQUIRED,
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,58 +1,11 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
/**
|
||||
* Activity Service
|
||||
@@ -105,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);
|
||||
|
||||
@@ -156,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,
|
||||
};
|
||||
}
|
||||
@@ -170,7 +123,7 @@ export class ActivityService {
|
||||
*/
|
||||
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
|
||||
// Try to get from cache
|
||||
const cacheKey = `activity:stats:${projectId}:${startDate?.getTime() || 'all'}:${endDate?.getTime() || 'now'}`;
|
||||
const cacheKey = Keys.Activity.stats(projectId, startDate?.getTime() || 'all', endDate?.getTime() || 'now');
|
||||
|
||||
try {
|
||||
const cached = await redis.get(cacheKey);
|
||||
@@ -178,7 +131,7 @@ export class ActivityService {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ACTIVITY] Failed to get stats from cache:', error);
|
||||
signale.warn('[ACTIVITY] Failed to get stats from cache:', error);
|
||||
}
|
||||
|
||||
// Default date range to last 30 days if not specified
|
||||
@@ -241,7 +194,7 @@ export class ActivityService {
|
||||
try {
|
||||
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
|
||||
} catch (error) {
|
||||
console.warn('[ACTIVITY] Failed to cache stats:', error);
|
||||
signale.warn('[ACTIVITY] Failed to cache stats:', error);
|
||||
}
|
||||
|
||||
return stats;
|
||||
@@ -261,7 +214,7 @@ export class ActivityService {
|
||||
await redis.del(...keys);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
|
||||
signale.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,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,
|
||||
@@ -448,16 +427,42 @@ 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 = {
|
||||
subject: email.subject,
|
||||
body: email.body,
|
||||
from: email.from,
|
||||
fromName: email.fromName,
|
||||
replyTo: email.replyTo,
|
||||
toName: email.toName,
|
||||
sourceType: email.sourceType,
|
||||
campaignName: email.campaign?.name,
|
||||
workflowName: email.workflowExecution?.workflow?.name,
|
||||
};
|
||||
|
||||
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT))) {
|
||||
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT)) && isInDateRange(email.sentAt)) {
|
||||
activities.push({
|
||||
id: `${email.id}_sent`,
|
||||
type: ActivityType.EMAIL_SENT,
|
||||
@@ -468,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,
|
||||
@@ -479,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,
|
||||
@@ -494,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,
|
||||
@@ -509,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,
|
||||
@@ -522,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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
/**
|
||||
* Time series data point for analytics
|
||||
@@ -57,14 +58,16 @@ export class AnalyticsService {
|
||||
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `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;
|
||||
@@ -190,7 +193,11 @@ export class AnalyticsService {
|
||||
const effectiveEndDate = endDate || now;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = `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);
|
||||
@@ -294,7 +301,12 @@ export class AnalyticsService {
|
||||
const effectiveEndDate = endDate || now;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = `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,44 +1,17 @@
|
||||
import {EmailSourceType} from '@plunk/db';
|
||||
import type {BillingLimitsResponse, CategoryUsage, LimitCheckResult} from '@plunk/types';
|
||||
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
|
||||
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -184,6 +157,7 @@ export class BillingLimitService {
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
billingLimitInbound: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -210,6 +184,9 @@ export class BillingLimitService {
|
||||
case EmailSourceType.TRANSACTIONAL:
|
||||
limit = project.billingLimitTransactional;
|
||||
break;
|
||||
case EmailSourceType.INBOUND:
|
||||
limit = project.billingLimitInbound;
|
||||
break;
|
||||
default:
|
||||
limit = null;
|
||||
}
|
||||
@@ -218,10 +195,12 @@ 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
|
||||
if (!project.subscription && !hasCustomLimits) {
|
||||
// Only enforce free tier limits if billing is enabled
|
||||
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||
const totalUsage = await this.getTotalUsage(projectId);
|
||||
const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
|
||||
const percentage = (totalUsage / freeLimit) * 100;
|
||||
@@ -236,6 +215,9 @@ export class BillingLimitService {
|
||||
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
||||
);
|
||||
|
||||
// Send email notification
|
||||
await this.sendLimitExceededEmail(projectId, project.name, totalUsage, freeLimit, 'Free Tier (All Types)');
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
warning: false,
|
||||
@@ -257,6 +239,16 @@ export class BillingLimitService {
|
||||
percentage,
|
||||
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
||||
);
|
||||
|
||||
// Send email notification (only once per month)
|
||||
await this.sendWarningEmail(
|
||||
projectId,
|
||||
project.name,
|
||||
totalUsage,
|
||||
freeLimit,
|
||||
percentage,
|
||||
'Free Tier (All Types)',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -297,6 +289,9 @@ export class BillingLimitService {
|
||||
if (project) {
|
||||
// Send notification about limit exceeded
|
||||
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
|
||||
|
||||
// Send email notification
|
||||
await this.sendLimitExceededEmail(projectId, project.name, usage, limit, sourceType);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -320,7 +315,17 @@ export class BillingLimitService {
|
||||
});
|
||||
|
||||
if (project) {
|
||||
await NtfyService.notifyBillingLimitApproaching(project.name, projectId, usage, limit, percentage, sourceType);
|
||||
await NtfyService.notifyBillingLimitApproaching(
|
||||
project.name,
|
||||
projectId,
|
||||
usage,
|
||||
limit,
|
||||
percentage,
|
||||
sourceType,
|
||||
);
|
||||
|
||||
// Send email notification (only once per month)
|
||||
await this.sendWarningEmail(projectId, project.name, usage, limit, percentage, sourceType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +373,7 @@ export class BillingLimitService {
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
billingLimitInbound: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -390,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
|
||||
@@ -412,17 +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
|
||||
if (!project.subscription && !hasCustomLimits) {
|
||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
|
||||
// Only show free tier limits if billing is enabled
|
||||
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage + inboundUsage;
|
||||
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
||||
const percentage = (totalUsage / limit) * 100;
|
||||
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
||||
const isBlocked = totalUsage >= limit;
|
||||
|
||||
// For free tier, show the same limit and total usage for all 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,
|
||||
@@ -436,6 +445,7 @@ export class BillingLimitService {
|
||||
workflows: sharedUsageInfo,
|
||||
campaigns: sharedUsageInfo,
|
||||
transactional: sharedUsageInfo,
|
||||
inbound: sharedUsageInfo,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
@@ -445,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) {
|
||||
@@ -455,7 +466,11 @@ export class BillingLimitService {
|
||||
|
||||
/**
|
||||
* Invalidate usage cache for a project
|
||||
* Call this when billing period resets or limits are changed
|
||||
* Primarily used in tests to reset cache state between scenarios.
|
||||
* In production, the cache naturally expires after 5 minutes.
|
||||
*
|
||||
* NOTE: Updating billing limits does NOT require clearing usage cache
|
||||
* (use clearNotificationCacheForChangedLimits instead)
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
*/
|
||||
@@ -465,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)));
|
||||
@@ -474,6 +490,79 @@ export class BillingLimitService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear notification cache keys for billing limits that have changed
|
||||
* This allows new warning/limit emails to be sent when updated limits are reached
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @param oldLimits - Previous billing limits
|
||||
* @param newLimits - New billing limits
|
||||
*/
|
||||
public static async clearNotificationCacheForChangedLimits(
|
||||
projectId: string,
|
||||
oldLimits: {
|
||||
workflows: number | null;
|
||||
campaigns: number | null;
|
||||
transactional: number | null;
|
||||
inbound: number | null;
|
||||
},
|
||||
newLimits: {
|
||||
workflows: number | null;
|
||||
campaigns: number | null;
|
||||
transactional: number | null;
|
||||
inbound: number | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
// Check workflows limit
|
||||
if (oldLimits.workflows !== newLimits.workflows) {
|
||||
keysToDelete.push(
|
||||
Keys.Billing.warningEmail(projectId, EmailSourceType.WORKFLOW, year, month),
|
||||
Keys.Billing.limitEmail(projectId, EmailSourceType.WORKFLOW, year, month),
|
||||
);
|
||||
}
|
||||
|
||||
// Check campaigns limit
|
||||
if (oldLimits.campaigns !== newLimits.campaigns) {
|
||||
keysToDelete.push(
|
||||
Keys.Billing.warningEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
|
||||
Keys.Billing.limitEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
|
||||
);
|
||||
}
|
||||
|
||||
// Check transactional limit
|
||||
if (oldLimits.transactional !== newLimits.transactional) {
|
||||
keysToDelete.push(
|
||||
Keys.Billing.warningEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
|
||||
Keys.Billing.limitEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
|
||||
);
|
||||
}
|
||||
|
||||
// Check inbound limit
|
||||
if (oldLimits.inbound !== newLimits.inbound) {
|
||||
keysToDelete.push(
|
||||
Keys.Billing.warningEmail(projectId, EmailSourceType.INBOUND, year, month),
|
||||
Keys.Billing.limitEmail(projectId, EmailSourceType.INBOUND, year, month),
|
||||
);
|
||||
}
|
||||
|
||||
if (keysToDelete.length > 0) {
|
||||
await Promise.all(keysToDelete.map(key => redis.del(key)));
|
||||
signale.debug(
|
||||
`[BILLING_LIMIT] Cleared notification cache for changed limits in project ${projectId} (${keysToDelete.length} keys)`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.warn(`[BILLING_LIMIT] Failed to clear notification cache for ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Redis cache key for usage count
|
||||
*/
|
||||
@@ -481,7 +570,7 @@ export class BillingLimitService {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
return `billing:usage:${projectId}:${sourceType}:${year}-${month}`;
|
||||
return Keys.Billing.usage(projectId, sourceType, year, month);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -493,4 +582,108 @@ export class BillingLimitService {
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
return {start, end};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send billing limit warning email to project members
|
||||
*/
|
||||
private static async sendWarningEmail(
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
usage: number,
|
||||
limit: number,
|
||||
percentage: number,
|
||||
sourceType: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if we've already sent this warning email this month
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
|
||||
|
||||
// Use SETNX (SET if Not eXists) to atomically check and set the flag
|
||||
// This prevents race conditions where multiple concurrent requests could all pass the check
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (!wasSet) {
|
||||
// Email was already sent this month
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = React.createElement(BillingLimitWarningEmail, {
|
||||
projectName,
|
||||
projectId,
|
||||
usage,
|
||||
limit,
|
||||
percentage,
|
||||
sourceType,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
|
||||
} catch (error) {
|
||||
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send billing limit exceeded email to project members
|
||||
*/
|
||||
private static async sendLimitExceededEmail(
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
usage: number,
|
||||
limit: number,
|
||||
sourceType: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if we've already sent this warning email this month
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
|
||||
|
||||
// Use SETNX (SET if Not eXists) to atomically check and set the flag
|
||||
// This prevents race conditions where multiple concurrent requests could all pass the check
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (!wasSet) {
|
||||
// Email was already sent this month
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = React.createElement(BillingLimitExceededEmail, {
|
||||
projectName,
|
||||
projectId,
|
||||
usage,
|
||||
limit,
|
||||
sourceType,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
|
||||
} catch (error) {
|
||||
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,24 @@
|
||||
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import type {FilterCondition} from '@plunk/types';
|
||||
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
|
||||
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
|
||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
||||
|
||||
import {BillingLimitService} from './BillingLimitService.js';
|
||||
import {DomainService} from './DomainService.js';
|
||||
import {EmailService} from './EmailService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {SegmentService} from './SegmentService.js';
|
||||
import {DASHBOARD_URI} from '../app/constants.js';
|
||||
import {DASHBOARD_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||
import {sendRawEmail} from './SESService.js';
|
||||
|
||||
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
|
||||
|
||||
export interface CreateCampaignData {
|
||||
name: string;
|
||||
description?: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
from: string;
|
||||
fromName?: string;
|
||||
replyTo?: string;
|
||||
audienceType: CampaignAudienceType;
|
||||
audienceCondition?: FilterCondition;
|
||||
segmentId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCampaignData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
from?: string;
|
||||
fromName?: string;
|
||||
replyTo?: string;
|
||||
audienceType?: CampaignAudienceType;
|
||||
audienceCondition?: FilterCondition;
|
||||
segmentId?: string;
|
||||
}
|
||||
|
||||
export class CampaignService {
|
||||
/**
|
||||
* Create a new campaign
|
||||
@@ -71,7 +48,7 @@ export class CampaignService {
|
||||
SegmentService.validateCondition(data.audienceCondition);
|
||||
}
|
||||
|
||||
// Create campaign
|
||||
// Create campaign with initial recipient count of 0
|
||||
const campaign = await prisma.campaign.create({
|
||||
data: {
|
||||
projectId,
|
||||
@@ -83,9 +60,10 @@ 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
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
@@ -94,10 +72,17 @@ export class CampaignService {
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate and update recipient count for the draft
|
||||
const recipientCount = await this.getRecipientCount(projectId, campaign);
|
||||
const updatedCampaign = await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {totalRecipients: recipientCount},
|
||||
});
|
||||
|
||||
// Send notification about campaign creation
|
||||
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
|
||||
|
||||
return campaign;
|
||||
return updatedCampaign;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,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) {
|
||||
@@ -147,10 +132,25 @@ export class CampaignService {
|
||||
delete (updateData as Record<string, unknown>).segmentId;
|
||||
}
|
||||
|
||||
return prisma.campaign.update({
|
||||
// Update the campaign first
|
||||
const updatedCampaign = await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// If audience-related fields changed and campaign is still a draft, recalculate totalRecipients
|
||||
const audienceChanged =
|
||||
data.audienceType !== undefined || data.segmentId !== undefined || data.audienceCondition !== undefined;
|
||||
|
||||
if (audienceChanged && updatedCampaign.status === CampaignStatus.DRAFT) {
|
||||
const recipientCount = await this.getRecipientCount(projectId, updatedCampaign);
|
||||
return prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {totalRecipients: recipientCount},
|
||||
});
|
||||
}
|
||||
|
||||
return updatedCampaign;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,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;
|
||||
|
||||
@@ -207,7 +207,7 @@ export class CampaignService {
|
||||
]);
|
||||
|
||||
return {
|
||||
campaigns,
|
||||
data: campaigns,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -252,7 +252,7 @@ export class CampaignService {
|
||||
const campaign = await this.get(projectId, campaignId);
|
||||
|
||||
// Create a new campaign with the same data but reset status and stats
|
||||
return prisma.campaign.create({
|
||||
const duplicatedCampaign = await prisma.campaign.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: `${campaign.name} (Copy)`,
|
||||
@@ -266,7 +266,7 @@ export class CampaignService {
|
||||
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
||||
segmentId: campaign.segmentId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
totalRecipients: 0,
|
||||
totalRecipients: 0, // Will be updated below
|
||||
sentCount: 0,
|
||||
deliveredCount: 0,
|
||||
openedCount: 0,
|
||||
@@ -274,6 +274,13 @@ export class CampaignService {
|
||||
bouncedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate and update recipient count
|
||||
const recipientCount = await this.getRecipientCount(projectId, duplicatedCampaign);
|
||||
return prisma.campaign.update({
|
||||
where: {id: duplicatedCampaign.id},
|
||||
data: {totalRecipients: recipientCount},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,6 +301,24 @@ export class CampaignService {
|
||||
throw new HttpException(400, 'Campaign has no recipients');
|
||||
}
|
||||
|
||||
// Check billing limits before scheduling/sending the campaign
|
||||
// This ensures users cannot schedule campaigns that would exceed their quota
|
||||
if (STRIPE_ENABLED) {
|
||||
const limitCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||
|
||||
// If there's a limit set, verify the campaign won't exceed it
|
||||
if (limitCheck.limit !== null) {
|
||||
const projectedUsage = limitCheck.usage + recipientCount;
|
||||
|
||||
if (projectedUsage > limitCheck.limit) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
`Cannot ${scheduledFor ? 'schedule' : 'send'} campaign: would exceed billing limit. Current usage: ${limitCheck.usage}/${limitCheck.limit} emails, campaign recipients: ${recipientCount}. Upgrade your plan or reduce campaign recipients.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduledFor) {
|
||||
// Schedule for later
|
||||
if (scheduledFor.getTime() <= Date.now()) {
|
||||
@@ -412,7 +437,7 @@ export class CampaignService {
|
||||
}
|
||||
|
||||
if (campaign.status !== CampaignStatus.SENDING) {
|
||||
console.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
|
||||
signale.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -426,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,
|
||||
@@ -458,21 +484,11 @@ export class CampaignService {
|
||||
replyTo: campaign.replyTo || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
||||
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
||||
// Continue with other contacts even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
// Update sent count
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {
|
||||
sentCount: {
|
||||
increment: contacts.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Queue next batch if there are more contacts
|
||||
if (hasMore && nextCursor) {
|
||||
await QueueService.queueCampaignBatch({
|
||||
@@ -482,27 +498,6 @@ export class CampaignService {
|
||||
limit,
|
||||
cursor: nextCursor,
|
||||
});
|
||||
} else {
|
||||
// All batches processed, mark campaign as SENT
|
||||
const completedCampaign = await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.SENT,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification about campaign send completed
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
completedCampaign.name,
|
||||
completedCampaign.project.name,
|
||||
completedCampaign.projectId,
|
||||
completedCampaign.totalRecipients || 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,6 +565,7 @@ export class CampaignService {
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {
|
||||
sentCount: sentEmails,
|
||||
deliveredCount: deliveredEmails,
|
||||
openedCount: openedEmails,
|
||||
clickedCount: clickedEmails,
|
||||
@@ -728,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');
|
||||
}
|
||||
@@ -764,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,17 +1,11 @@
|
||||
import {type Contact, Prisma} from '@plunk/db';
|
||||
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
|
||||
@@ -22,7 +16,7 @@ export class ContactService {
|
||||
limit = 20,
|
||||
cursor?: string,
|
||||
search?: string,
|
||||
): Promise<PaginatedContacts> {
|
||||
): Promise<CursorPaginatedResponse<Contact>> {
|
||||
const where: Prisma.ContactWhereInput = {
|
||||
projectId,
|
||||
...(search
|
||||
@@ -57,7 +51,7 @@ export class ContactService {
|
||||
const total = !cursor ? await prisma.contact.count({where}) : 0;
|
||||
|
||||
return {
|
||||
contacts: results,
|
||||
data: results,
|
||||
total,
|
||||
cursor: nextCursor,
|
||||
hasMore,
|
||||
@@ -204,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({
|
||||
@@ -224,11 +219,29 @@ export class ContactService {
|
||||
// Merge new data (if provided)
|
||||
if (data) {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Skip reserved fields
|
||||
if (key === 'plunk_id' || key === 'plunk_email') {
|
||||
// Skip reserved system-generated fields
|
||||
// These fields are dynamically added during template rendering and cannot be overridden
|
||||
const reservedFields = [
|
||||
'plunk_id',
|
||||
'plunk_email',
|
||||
'id',
|
||||
'email',
|
||||
'unsubscribeUrl',
|
||||
'subscribeUrl',
|
||||
'manageUrl',
|
||||
];
|
||||
if (reservedFields.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate locale field (special user-settable field)
|
||||
// Only validate type - any locale string is accepted since we default to English if unsupported
|
||||
if (key === 'locale') {
|
||||
if (value !== null && value !== undefined && typeof value !== 'string') {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-persistent data format: { value: "...", persistent: false }
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
@@ -255,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} : {}),
|
||||
},
|
||||
});
|
||||
@@ -275,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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -297,11 +310,18 @@ export class ContactService {
|
||||
Object.assign(mergedData, contact.data);
|
||||
}
|
||||
|
||||
// Explicitly expose locale as a predefined field (available in templates)
|
||||
// This ensures locale is always accessible even if not in contact.data
|
||||
if (mergedData.locale === undefined) {
|
||||
mergedData.locale = null;
|
||||
}
|
||||
|
||||
// Add temporary (non-persistent) data
|
||||
if (temporaryData) {
|
||||
for (const [key, value] of Object.entries(temporaryData)) {
|
||||
// Skip reserved fields
|
||||
if (key === 'plunk_id' || key === 'plunk_email') {
|
||||
// Skip reserved system-generated fields
|
||||
const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
|
||||
if (reservedFields.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -339,6 +359,25 @@ export class ContactService {
|
||||
return contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project by contact ID
|
||||
* Used to fetch project settings for public endpoints
|
||||
*/
|
||||
public static async getProjectByContactId(contactId: string): Promise<{language: string} | null> {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: {id: contactId},
|
||||
select: {
|
||||
project: {
|
||||
select: {
|
||||
language: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return contact?.project || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PUBLIC: Subscribe a contact
|
||||
*/
|
||||
@@ -635,6 +674,117 @@ export class ContactService {
|
||||
return {deletedFrom: result};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk subscribe contacts
|
||||
* Updates multiple contacts to subscribed=true in batches
|
||||
*/
|
||||
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||
// Verify all contacts belong to this project
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
projectId,
|
||||
},
|
||||
select: {id: true, subscribed: true},
|
||||
});
|
||||
|
||||
const validIds = contacts.map(c => c.id);
|
||||
|
||||
if (validIds.length === 0) {
|
||||
return {updated: 0};
|
||||
}
|
||||
|
||||
// Only update contacts that are currently unsubscribed
|
||||
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
|
||||
|
||||
if (unsubscribedIds.length === 0) {
|
||||
return {updated: 0};
|
||||
}
|
||||
|
||||
// Update in a single query for performance
|
||||
const result = await prisma.contact.updateMany({
|
||||
where: {
|
||||
id: {in: unsubscribedIds},
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
subscribed: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Track events for changed contacts sequentially to avoid database deadlocks
|
||||
// Process in background to avoid blocking the API response
|
||||
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
|
||||
// Silently ignore errors in tests due to cleanup race conditions
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error('[ContactService] Failed to track bulk subscribe events:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return {updated: result.count};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk unsubscribe contacts
|
||||
*/
|
||||
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
projectId,
|
||||
},
|
||||
select: {id: true, subscribed: true},
|
||||
});
|
||||
|
||||
const validIds = contacts.map(c => c.id);
|
||||
|
||||
if (validIds.length === 0) {
|
||||
return {updated: 0};
|
||||
}
|
||||
|
||||
// Only update contacts that are currently subscribed
|
||||
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
|
||||
|
||||
if (subscribedIds.length === 0) {
|
||||
return {updated: 0};
|
||||
}
|
||||
|
||||
const result = await prisma.contact.updateMany({
|
||||
where: {
|
||||
id: {in: subscribedIds},
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
subscribed: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Track events for changed contacts sequentially to avoid database deadlocks
|
||||
// Process in background to avoid blocking the API response
|
||||
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
|
||||
// Silently ignore errors in tests due to cleanup race conditions
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return {updated: result.count};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete contacts
|
||||
*/
|
||||
public static async bulkDelete(projectId: string, contactIds: string[]): Promise<{deleted: number}> {
|
||||
const result = await prisma.contact.deleteMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return {deleted: result.count};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter condition (recursive)
|
||||
*/
|
||||
@@ -679,4 +829,28 @@ export class ContactService {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track events sequentially to avoid database deadlocks
|
||||
* Processes events one at a time with error handling
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private static async trackEventsSequentially(
|
||||
projectId: string,
|
||||
eventName: string,
|
||||
contactIds: string[],
|
||||
): Promise<void> {
|
||||
for (const contactId of contactIds) {
|
||||
try {
|
||||
await EventService.trackEvent(projectId, eventName, contactId);
|
||||
} catch (error) {
|
||||
// Log error but continue processing remaining events
|
||||
// Suppress logging in test environments to reduce noise from cleanup race conditions
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error(`[ContactService] Failed to track event ${eventName} for contact ${contactId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} 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,
|
||||
});
|
||||
@@ -407,7 +434,7 @@ export class EmailService {
|
||||
sentAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[EMAIL] Failed to send email ${emailId}:`, error);
|
||||
signale.error(`[EMAIL] Failed to send email ${emailId}:`, error);
|
||||
|
||||
// Mark as failed
|
||||
await prisma.email.update({
|
||||
@@ -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,
|
||||
@@ -585,6 +650,378 @@ export class EmailService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
* rather than the visual editor. Mirrors the same logic in apps/web/src/lib/emailStyles.ts.
|
||||
*/
|
||||
private static detectCustomHtmlPatterns(html: string): boolean {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
const classValue = match[1];
|
||||
if (!classValue) continue;
|
||||
const classes = classValue.split(/\s+/).filter((c: string) => c.length > 0);
|
||||
const allowedPrefixes = ['prose', 'variable-', 'email-image', 'ProseMirror', 'resizable-image', 'selected', 'resize-handle'];
|
||||
const hasDisallowedClass = classes.some((cls: string) => !allowedPrefixes.some((prefix: string) => cls.startsWith(prefix)));
|
||||
if (hasDisallowedClass) {
|
||||
hasCustomClasses = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps visual editor content with a full HTML document and prose styles.
|
||||
* Mirrors wrapEmailWithStyles() in apps/web/src/lib/emailStyles.ts so sent emails
|
||||
* match the preview modal exactly.
|
||||
*/
|
||||
private static wrapWithEmailStyles(htmlBody: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
/* Base reset */
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Tailwind Typography (prose) base styles */
|
||||
.prose {
|
||||
color: #374151;
|
||||
max-width: 600px;
|
||||
}
|
||||
.prose [class~="lead"] {
|
||||
color: #4b5563;
|
||||
font-size: 1.25em;
|
||||
line-height: 1.6;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
.prose a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
.prose strong {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose ol, .prose ul {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.prose ol > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose ul > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose > ul > li p {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose > ul > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ul > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
.prose blockquote {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
color: #111827;
|
||||
border-left-width: 0.25rem;
|
||||
border-left-color: #e5e7eb;
|
||||
quotes: "\\201C""\\201D""\\2018""\\2019";
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
padding-left: 1em;
|
||||
}
|
||||
.prose h1 {
|
||||
color: #111827;
|
||||
font-weight: 800;
|
||||
font-size: 2.25em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8888889em;
|
||||
line-height: 1.1111111;
|
||||
}
|
||||
.prose h2 {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.3333333;
|
||||
}
|
||||
.prose h3 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prose h4 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prose img {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure > * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose code {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.prose code::before {
|
||||
content: "\`";
|
||||
}
|
||||
.prose code::after {
|
||||
content: "\`";
|
||||
}
|
||||
.prose pre {
|
||||
color: #e5e7eb;
|
||||
background-color: #1f2937;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.8571429em;
|
||||
padding-right: 1.1428571em;
|
||||
padding-bottom: 0.8571429em;
|
||||
padding-left: 1.1428571em;
|
||||
}
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.prose pre code::before {
|
||||
content: none;
|
||||
}
|
||||
.prose pre code::after {
|
||||
content: none;
|
||||
}
|
||||
.prose table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
text-align: left;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.prose thead {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #d1d5db;
|
||||
}
|
||||
.prose thead th {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
vertical-align: bottom;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose tbody tr {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
.prose tbody tr:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
.prose tbody td {
|
||||
vertical-align: top;
|
||||
padding-top: 0.5714286em;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose p {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
/* prose-sm modifier */
|
||||
.prose-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7142857;
|
||||
}
|
||||
.prose-sm p {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
}
|
||||
.prose-sm h1 {
|
||||
font-size: 2.1428571em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.prose-sm h2 {
|
||||
font-size: 1.4285714em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.prose-sm h3 {
|
||||
font-size: 1.2857143em;
|
||||
margin-top: 1.5555556em;
|
||||
margin-bottom: 0.4444444em;
|
||||
line-height: 1.5555556;
|
||||
}
|
||||
.prose-sm h4 {
|
||||
margin-top: 1.4285714em;
|
||||
margin-bottom: 0.5714286em;
|
||||
line-height: 1.4285714;
|
||||
}
|
||||
.prose-sm img {
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
}
|
||||
.prose-sm ol, .prose-sm ul {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
padding-left: 1.5714286em;
|
||||
}
|
||||
.prose-sm li {
|
||||
margin-top: 0.2857143em;
|
||||
margin-bottom: 0.2857143em;
|
||||
}
|
||||
|
||||
/* max-w-none utility */
|
||||
.max-w-none {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Custom editor styles */
|
||||
.variable-highlight, .variable-placeholder, .variable-mention {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose th, .prose td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
background-color: #f3f4f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-wrapper {
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container img {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
${htmlBody}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile HTML email with optional unsubscribe footer and badge
|
||||
* Adds unsubscribe link and Plunk badge for free tier users (only when billing is enabled)
|
||||
@@ -600,22 +1037,43 @@ export class EmailService {
|
||||
project: Project;
|
||||
includeUnsubscribe?: boolean;
|
||||
}): string {
|
||||
let html = content;
|
||||
// Wrap visual editor content with prose styles so the sent email matches the preview modal.
|
||||
// Custom HTML (from the HTML editor) already carries its own styles and is used as-is.
|
||||
let html = this.detectCustomHtmlPatterns(content) ? content : this.wrapWithEmailStyles(content);
|
||||
|
||||
const unsubscribeHtml = includeUnsubscribe
|
||||
? `<table align="center" width="100%" style="max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; font-family: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; border: 0; cellpadding: 0; cellspacing: 0;" role="presentation">
|
||||
? (() => {
|
||||
// Get contact-level locale (overrides project language)
|
||||
const contactLocale =
|
||||
contact.data &&
|
||||
typeof contact.data === 'object' &&
|
||||
!Array.isArray(contact.data) &&
|
||||
'locale' in contact.data &&
|
||||
typeof contact.data.locale === 'string'
|
||||
? contact.data.locale
|
||||
: null;
|
||||
|
||||
// Get translator for contact's locale or project's language
|
||||
const translator = createTranslatorSync(contactLocale || project.language || 'en');
|
||||
const unsubscribeText = translator.t('email.footer.unsubscribeText', {
|
||||
projectName: project.name,
|
||||
});
|
||||
const updatePreferencesText = translator.t('email.footer.updatePreferences');
|
||||
|
||||
return `<table align="center" width="100%" style="max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; font-family: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; border: 0; cellpadding: 0; cellspacing: 0;" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<hr style="border: none; border-top: 1px solid #eaeaea; width: 100%; margin-top: 12px; margin-bottom: 12px;">
|
||||
<p style="font-size: 12px; line-height: 24px; margin: 16px 0; text-align: center; color: rgb(64, 64, 64);">
|
||||
You received this email because you agreed to receive emails from ${project.name}. If you no longer wish to receive emails like this, please
|
||||
<a href="${DASHBOARD_URI}/unsubscribe/${contact.id}">update your preferences</a>.
|
||||
${unsubscribeText}
|
||||
<a href="${DASHBOARD_URI}/unsubscribe/${contact.id}">${updatePreferencesText}</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>`
|
||||
</table>`;
|
||||
})()
|
||||
: '';
|
||||
|
||||
// Add Plunk badge if billing is enabled and project has no subscription (free tier)
|
||||
@@ -635,7 +1093,7 @@ export class EmailService {
|
||||
<tr>
|
||||
<td style="width:180px;">
|
||||
<a href="${LANDING_URI}?ref=badge" target="_blank">
|
||||
<img height="auto" src="https://cdn.useplunk.com/badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180" />
|
||||
<img alt="Powered by Plunk" height="auto" src="https://cdn.useplunk.com/badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import {promises as dns} from 'dns';
|
||||
import {run} from '@zootools/email-spell-checker';
|
||||
import type {EmailVerificationResult} from '@plunk/types';
|
||||
import {redis} from '../database/redis.js';
|
||||
|
||||
const DISPOSABLE_DOMAINS_URL =
|
||||
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
|
||||
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
|
||||
const PERSONAL_DOMAINS_URL =
|
||||
'https://gist.githubusercontent.com/ammarshah/f5c2624d767f91a7cbdc4e54db8dd0bf/raw/660fd949eba09c0b86574d9d3aa0f2137161fc7c/all_email_provider_domains.txt';
|
||||
const PERSONAL_DOMAINS_CACHE_KEY = 'email:personal_domains';
|
||||
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
|
||||
|
||||
// Known email forwarding/alias services
|
||||
const FORWARDING_DOMAINS = new Set([
|
||||
'privaterelay.appleid.com', // Apple Sign In
|
||||
'mozmail.com', // Firefox Relay
|
||||
'simplelogin.com', // SimpleLogin
|
||||
'simplelogin.fr',
|
||||
'simplelogin.co',
|
||||
'simplelogin.io',
|
||||
'aleeas.com',
|
||||
'slmail.me',
|
||||
'dralias.com',
|
||||
'8shield.net',
|
||||
'anonaddy.com', // Addy.io
|
||||
'anonaddy.me',
|
||||
'addy.io',
|
||||
'duck.com', // DuckDuckGo
|
||||
'33mail.com', // 33mail
|
||||
'33m.co',
|
||||
'passmail.com', // Proton Pass
|
||||
'passmail.net',
|
||||
'passinbox.com',
|
||||
'passfwd.com',
|
||||
'y.yo.fr',
|
||||
'opayq.com', // IronVest (formerly Blur)
|
||||
'cloak.id', // Cloaked
|
||||
'erine.email', // Erine
|
||||
'use.startmail.com', // StartMail
|
||||
]);
|
||||
|
||||
export class EmailVerificationService {
|
||||
private static disposableDomainsSet: Set<string> | null = null;
|
||||
private static personalDomainsSet: Set<string> | null = null;
|
||||
|
||||
/**
|
||||
* Verify an email address
|
||||
* - Checks for NS records (proves domain exists in DNS)
|
||||
* - Checks for MX records (required for receiving email)
|
||||
* - Checks for A/AAAA records (informational - indicates if domain has a website)
|
||||
* - Detects disposable email addresses
|
||||
* - Detects personal/free email providers (Gmail, Hotmail, etc.)
|
||||
* - Detects forwarding/alias email addresses
|
||||
* - Suggests corrections for common typos
|
||||
*/
|
||||
static async verifyEmail(email: string): Promise<EmailVerificationResult> {
|
||||
const result: EmailVerificationResult = {
|
||||
email,
|
||||
valid: true,
|
||||
isDisposable: false,
|
||||
isAlias: false,
|
||||
isTypo: false,
|
||||
isPlusAddressed: false,
|
||||
isPersonalEmail: false,
|
||||
domainExists: false,
|
||||
hasWebsite: false,
|
||||
hasMxRecords: false,
|
||||
reasons: [],
|
||||
};
|
||||
|
||||
// Extract domain from email
|
||||
const emailParts = email.split('@');
|
||||
if (emailParts.length !== 2) {
|
||||
result.valid = false;
|
||||
result.reasons.push('Invalid email format');
|
||||
return result;
|
||||
}
|
||||
|
||||
const domain = emailParts[1]!; // Safe to assert, we already validated length
|
||||
|
||||
// Check if email is from a disposable domain using GitHub list
|
||||
result.isDisposable = await this.isDisposableDomain(domain);
|
||||
|
||||
// Check if email is from a personal/free email provider
|
||||
result.isPersonalEmail = await this.isPersonalEmailDomain(domain);
|
||||
|
||||
// Check if email is from a known forwarding/alias service
|
||||
result.isAlias = this.isForwardingDomain(domain);
|
||||
|
||||
// Check for plus addressing
|
||||
result.isPlusAddressed = emailParts[0]!.includes('+');
|
||||
|
||||
// Check for common typos and suggest corrections
|
||||
const typoCheck = run({email});
|
||||
if (typoCheck && typoCheck.address && typoCheck.address !== email) {
|
||||
result.suggestedEmail = typoCheck.full;
|
||||
result.reasons.push(`Possible typo detected, did you mean ${typoCheck.domain}?`);
|
||||
result.isTypo = true;
|
||||
}
|
||||
|
||||
// Step 1: Check NS records - proves the domain exists in DNS
|
||||
try {
|
||||
const nsRecords = await dns.resolveNs(domain);
|
||||
result.domainExists = nsRecords && nsRecords.length > 0;
|
||||
} catch {
|
||||
result.domainExists = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain does not exist (no nameservers found)');
|
||||
// If domain doesn't exist, no point checking MX/A records
|
||||
return result;
|
||||
}
|
||||
|
||||
// Step 2: Check MX records - required for receiving email
|
||||
try {
|
||||
const mxRecords = await dns.resolveMx(domain);
|
||||
result.hasMxRecords = mxRecords && mxRecords.length > 0;
|
||||
if (!result.hasMxRecords) {
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain cannot receive email (no MX records found)');
|
||||
}
|
||||
} catch {
|
||||
result.hasMxRecords = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain cannot receive email (no MX records found)');
|
||||
}
|
||||
|
||||
// Step 3: Check if domain has A/AAAA records - informational only
|
||||
// This indicates if the domain has a website/web server
|
||||
// Doesn't affect email validity since email only requires MX records
|
||||
try {
|
||||
await dns.resolve(domain, 'A');
|
||||
result.hasWebsite = true;
|
||||
} catch {
|
||||
// Try AAAA records if A records fail
|
||||
try {
|
||||
await dns.resolve(domain, 'AAAA');
|
||||
result.hasWebsite = true;
|
||||
} catch {
|
||||
// Domain doesn't have A/AAAA records (no website), but this is OK for email
|
||||
result.hasWebsite = false;
|
||||
}
|
||||
}
|
||||
|
||||
// If no issues were found, add a success reason
|
||||
if (result.valid && result.reasons.length === 0) {
|
||||
result.reasons.push('Email appears to be valid');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache the disposable domains list from GitHub
|
||||
* Uses Redis for caching with 24-hour TTL
|
||||
* Falls back to in-memory cache if Redis fails
|
||||
*/
|
||||
private static async getDisposableDomains(): Promise<Set<string>> {
|
||||
// Return in-memory cache if available
|
||||
if (this.disposableDomainsSet) {
|
||||
return this.disposableDomainsSet;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get from Redis cache first
|
||||
const cached = await redis.get(DISPOSABLE_DOMAINS_CACHE_KEY);
|
||||
if (cached) {
|
||||
const domains = JSON.parse(cached) as string[];
|
||||
this.disposableDomainsSet = new Set(domains);
|
||||
return this.disposableDomainsSet;
|
||||
}
|
||||
|
||||
// Fetch from GitHub if not in cache
|
||||
const response = await fetch(DISPOSABLE_DOMAINS_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch disposable domains: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const domains = text
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
|
||||
|
||||
// Cache in Redis
|
||||
await redis.set(DISPOSABLE_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
|
||||
|
||||
// Cache in memory
|
||||
this.disposableDomainsSet = new Set(domains);
|
||||
return this.disposableDomainsSet;
|
||||
} catch (error) {
|
||||
console.error('Error fetching disposable domains:', error);
|
||||
// Return empty set as fallback - don't block email verification
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is disposable
|
||||
*/
|
||||
private static async isDisposableDomain(domain: string): Promise<boolean> {
|
||||
const disposableDomains = await this.getDisposableDomains();
|
||||
return disposableDomains.has(domain.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is a known forwarding/alias service
|
||||
*/
|
||||
private static isForwardingDomain(domain: string): boolean {
|
||||
return FORWARDING_DOMAINS.has(domain.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache the personal email domains list from GitHub
|
||||
* Uses Redis for caching with 24-hour TTL
|
||||
* Falls back to in-memory cache if Redis fails
|
||||
*/
|
||||
private static async getPersonalEmailDomains(): Promise<Set<string>> {
|
||||
// Return in-memory cache if available
|
||||
if (this.personalDomainsSet) {
|
||||
return this.personalDomainsSet;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get from Redis cache first
|
||||
const cached = await redis.get(PERSONAL_DOMAINS_CACHE_KEY);
|
||||
if (cached) {
|
||||
const domains = JSON.parse(cached) as string[];
|
||||
this.personalDomainsSet = new Set(domains);
|
||||
return this.personalDomainsSet;
|
||||
}
|
||||
|
||||
// Fetch from GitHub if not in cache
|
||||
const response = await fetch(PERSONAL_DOMAINS_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch personal email domains: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const domains = text
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
|
||||
|
||||
// Cache in Redis
|
||||
await redis.set(PERSONAL_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
|
||||
|
||||
// Cache in memory
|
||||
this.personalDomainsSet = new Set(domains);
|
||||
return this.personalDomainsSet;
|
||||
} catch (error) {
|
||||
console.error('Error fetching personal email domains:', error);
|
||||
// Return empty set as fallback - don't block email verification
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is a personal/free email provider
|
||||
*/
|
||||
private static async isPersonalEmailDomain(domain: string): Promise<boolean> {
|
||||
const personalDomains = await this.getPersonalEmailDomains();
|
||||
return personalDomains.has(domain.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import type {Event} from '@plunk/db';
|
||||
import {Prisma} from '@plunk/db';
|
||||
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
||||
|
||||
@@ -30,7 +33,7 @@ export class EventService {
|
||||
contactId,
|
||||
emailId,
|
||||
name: eventName,
|
||||
data: data ? (data as Prisma.InputJsonValue) : undefined,
|
||||
data: data ? toPrismaJson(data) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,11 +51,11 @@ export class EventService {
|
||||
* Should be called when workflows are enabled/disabled or updated
|
||||
*/
|
||||
public static async invalidateWorkflowCache(projectId: string): Promise<void> {
|
||||
const cacheKey = `workflows:enabled:${projectId}`;
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
try {
|
||||
await redis.del(cacheKey);
|
||||
} catch (error) {
|
||||
console.warn('[EVENT] Failed to invalidate workflow cache:', error);
|
||||
signale.warn('[EVENT] Failed to invalidate workflow cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,9 +290,9 @@ export class EventService {
|
||||
* @param eventName - The event name to delete
|
||||
*/
|
||||
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
|
||||
// Prevent deletion of system events
|
||||
if (eventName.startsWith('email.') || eventName.startsWith('segment.')) {
|
||||
throw new Error('Cannot delete system events (email.* or segment.*)');
|
||||
// Prevent deletion of reserved system events
|
||||
if (this.isReservedEvent(eventName)) {
|
||||
throw new Error(`Cannot delete reserved system event: ${eventName}`);
|
||||
}
|
||||
|
||||
// Check if event is in use
|
||||
@@ -311,6 +314,36 @@ export class EventService {
|
||||
return {deletedCount: result.count};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event name is reserved for system use
|
||||
* Reserved patterns:
|
||||
* - email.* (email.sent, email.delivery, email.open, email.click, email.bounce, email.complaint)
|
||||
* - contact.subscribed, contact.unsubscribed
|
||||
* - segment.*.entry, segment.*.exit
|
||||
*
|
||||
* @param eventName - The event name to check
|
||||
* @returns true if the event is reserved, false otherwise
|
||||
*/
|
||||
public static isReservedEvent(eventName: string): boolean {
|
||||
// Email events: email.*
|
||||
if (eventName.startsWith('email.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Contact events: contact.subscribed, contact.unsubscribed
|
||||
if (eventName === 'contact.subscribed' || eventName === 'contact.unsubscribed') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Segment events: segment.*.entry, segment.*.exit
|
||||
// Pattern: segment.<slug>.entry or segment.<slug>.exit
|
||||
if (eventName.startsWith('segment.') && (eventName.endsWith('.entry') || eventName.endsWith('.exit'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger workflows based on an event
|
||||
* Uses Redis caching for enabled workflows to improve performance
|
||||
@@ -322,7 +355,7 @@ export class EventService {
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
// Try to get workflows from cache
|
||||
const cacheKey = `workflows:enabled:${projectId}`;
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
let workflows;
|
||||
|
||||
try {
|
||||
@@ -331,7 +364,7 @@ export class EventService {
|
||||
workflows = JSON.parse(cached);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[EVENT] Failed to get workflows from cache:', error);
|
||||
signale.warn('[EVENT] Failed to get workflows from cache:', error);
|
||||
}
|
||||
|
||||
// If not in cache, fetch from database
|
||||
@@ -353,7 +386,7 @@ export class EventService {
|
||||
try {
|
||||
await redis.setex(cacheKey, 300, JSON.stringify(workflows));
|
||||
} catch (error) {
|
||||
console.warn('[EVENT] Failed to cache workflows:', error);
|
||||
signale.warn('[EVENT] Failed to cache workflows:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +401,7 @@ export class EventService {
|
||||
} else {
|
||||
// If event is not contact-specific, you might want different logic
|
||||
// For example, trigger for all contacts, or skip
|
||||
console.log(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
|
||||
signale.info(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,7 +427,7 @@ export class EventService {
|
||||
});
|
||||
|
||||
if (!workflow || workflow.steps.length === 0) {
|
||||
console.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
|
||||
signale.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -429,7 +462,7 @@ export class EventService {
|
||||
const triggerStep = workflow.steps[0];
|
||||
|
||||
if (!triggerStep) {
|
||||
console.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
|
||||
signale.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -440,18 +473,18 @@ export class EventService {
|
||||
contactId,
|
||||
status: 'RUNNING',
|
||||
currentStepId: triggerStep.id,
|
||||
context: context ? (context as Prisma.InputJsonValue) : undefined,
|
||||
context: context ? toPrismaJson(context) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
signale.info(
|
||||
`[EVENT] Started workflow ${workflowId} execution ${execution.id} for contact ${contactId}${workflow.allowReentry ? ' (re-entry allowed)' : ''}`,
|
||||
);
|
||||
|
||||
// Start executing the workflow
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
} catch (error) {
|
||||
console.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
|
||||
signale.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,57 +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
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue Configuration
|
||||
*/
|
||||
@@ -181,6 +145,19 @@ export const apiRequestCleanupQueue = new Queue<ApiRequestCleanupJobData>('api-r
|
||||
},
|
||||
});
|
||||
|
||||
export const bulkContactQueue = new Queue<BulkContactActionJobData>('bulk-contact-actions', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 2, // Limited retries for bulk operations
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000,
|
||||
},
|
||||
removeOnComplete: 50, // Keep last 50 completed bulk operations
|
||||
removeOnFail: 100, // Keep last 100 failed bulk operations
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Queue Service - Centralized queue management
|
||||
*/
|
||||
@@ -305,14 +282,72 @@ 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;
|
||||
const failedReason = job.failedReason;
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
state,
|
||||
progress,
|
||||
result: returnValue,
|
||||
data: job.data,
|
||||
failedReason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue bulk contact action job
|
||||
*/
|
||||
public static async queueBulkContactAction(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
||||
): Promise<Job<BulkContactActionJobData>> {
|
||||
return bulkContactQueue.add(
|
||||
'bulk-contact-action',
|
||||
{projectId, contactIds, operation},
|
||||
{
|
||||
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bulk action job status and progress
|
||||
* @param jobId - The job ID
|
||||
* @param projectId - The project ID to verify authorization
|
||||
* @returns Job status or null if not found or unauthorized
|
||||
*/
|
||||
public static async getBulkActionJobStatus(jobId: string, projectId: string) {
|
||||
const job = await bulkContactQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Security: Verify that the job belongs to the requesting project
|
||||
if (job.data.projectId !== projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = await job.getState();
|
||||
const progress = job.progress;
|
||||
const returnValue = job.returnvalue;
|
||||
@@ -354,6 +389,7 @@ export class QueueService {
|
||||
segmentCountCounts,
|
||||
domainVerificationCounts,
|
||||
apiRequestCleanupCounts,
|
||||
bulkContactCounts,
|
||||
] = await Promise.all([
|
||||
emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
campaignQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
@@ -363,6 +399,7 @@ export class QueueService {
|
||||
segmentCountQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
bulkContactQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -374,6 +411,7 @@ export class QueueService {
|
||||
segmentCount: segmentCountCounts,
|
||||
domainVerification: domainVerificationCounts,
|
||||
apiRequestCleanup: apiRequestCleanupCounts,
|
||||
bulkContact: bulkContactCounts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -390,6 +428,7 @@ export class QueueService {
|
||||
segmentCountQueue.pause(),
|
||||
domainVerificationQueue.pause(),
|
||||
apiRequestCleanupQueue.pause(),
|
||||
bulkContactQueue.pause(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -406,6 +445,7 @@ export class QueueService {
|
||||
segmentCountQueue.resume(),
|
||||
domainVerificationQueue.resume(),
|
||||
apiRequestCleanupQueue.resume(),
|
||||
bulkContactQueue.resume(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -430,6 +470,8 @@ export class QueueService {
|
||||
segmentCountQueue.clean(gracePeriod * 7, 50, 'failed'),
|
||||
domainVerificationQueue.clean(gracePeriod, 10, 'completed'),
|
||||
domainVerificationQueue.clean(gracePeriod * 7, 50, 'failed'),
|
||||
bulkContactQueue.clean(gracePeriod, 50, 'completed'),
|
||||
bulkContactQueue.clean(gracePeriod * 7, 100, 'failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -514,6 +556,7 @@ export class QueueService {
|
||||
segmentCountQueue.close(),
|
||||
domainVerificationQueue.close(),
|
||||
apiRequestCleanupQueue.close(),
|
||||
bulkContactQueue.close(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +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';
|
||||
|
||||
/**
|
||||
@@ -44,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,
|
||||
@@ -69,13 +70,13 @@ export async function initializeBucket(): Promise<void> {
|
||||
Bucket: S3_BUCKET,
|
||||
}),
|
||||
);
|
||||
console.log(`[S3] Created bucket: ${S3_BUCKET}`);
|
||||
signale.info(`[S3] Created bucket: ${S3_BUCKET}`);
|
||||
} catch (createError) {
|
||||
console.error('[S3] Failed to create bucket:', createError);
|
||||
signale.error('[S3] Failed to create bucket:', createError);
|
||||
throw createError;
|
||||
}
|
||||
} else {
|
||||
console.error('[S3] Failed to check bucket:', error);
|
||||
signale.error('[S3] Failed to check bucket:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -103,10 +104,10 @@ export async function initializeBucket(): Promise<void> {
|
||||
);
|
||||
|
||||
if (!bucketExists) {
|
||||
console.log(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
|
||||
signale.info(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
|
||||
}
|
||||
} catch (policyError) {
|
||||
console.error('[S3] Failed to set bucket policy:', policyError);
|
||||
signale.error('[S3] Failed to set bucket policy:', policyError);
|
||||
// Don't throw - bucket was created but policy failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {SES} from '@aws-sdk/client-ses';
|
||||
import signale from 'signale';
|
||||
|
||||
import {
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
@@ -6,7 +7,8 @@ import {
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
DASHBOARD_URI,
|
||||
SES_CONFIGURATION_SET,
|
||||
SES_CONFIGURATION_SET_NO_TRACKING
|
||||
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
@@ -38,6 +40,8 @@ interface SendRawEmailParams {
|
||||
filename: string;
|
||||
content: string; // Base64 encoded
|
||||
contentType: string;
|
||||
contentId?: string;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
}[]
|
||||
| null;
|
||||
tracking?: boolean;
|
||||
@@ -99,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
|
||||
@@ -116,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)
|
||||
@@ -136,34 +149,71 @@ ${
|
||||
}
|
||||
${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
|
||||
const configurationSetName =
|
||||
TRACKING_TOGGLE_ENABLED && !tracking ? SES_CONFIGURATION_SET_NO_TRACKING : SES_CONFIGURATION_SET;
|
||||
|
||||
// Send via SES
|
||||
const response = await ses.sendRawEmail({
|
||||
Destinations: destinations,
|
||||
ConfigurationSetName: tracking ? SES_CONFIGURATION_SET : SES_CONFIGURATION_SET_NO_TRACKING,
|
||||
ConfigurationSetName: configurationSetName,
|
||||
RawMessage: {
|
||||
Data: new TextEncoder().encode(rawMessage),
|
||||
},
|
||||
@@ -250,3 +300,33 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
|
||||
ForwardingEnabled: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a verified domain identity from AWS SES
|
||||
*/
|
||||
export const deleteIdentity = async (domain: string): Promise<void> => {
|
||||
await ses.deleteIdentity({Identity: domain});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get AWS SES account sending quota and rate limit
|
||||
* @returns MaxSendRate (emails per second) or null if the call fails
|
||||
*/
|
||||
export const getSendingQuota = async (): Promise<{
|
||||
maxSendRate: number;
|
||||
max24HourSend: number;
|
||||
sentLast24Hours: number;
|
||||
} | null> => {
|
||||
try {
|
||||
const quota = await ses.getSendQuota({});
|
||||
|
||||
return {
|
||||
maxSendRate: quota.MaxSendRate ?? 14, // Default to sandbox limit if not provided
|
||||
max24HourSend: quota.Max24HourSend ?? 200, // Default sandbox daily limit
|
||||
sentLast24Hours: quota.SentLast24Hours ?? 0,
|
||||
};
|
||||
} catch (error) {
|
||||
signale.error('[SES] Failed to fetch sending quota:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
* Security thresholds for bounce and complaint rates
|
||||
@@ -14,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 {
|
||||
@@ -45,7 +57,6 @@ interface SecurityStatus {
|
||||
}
|
||||
|
||||
export class SecurityService {
|
||||
private static readonly CACHE_PREFIX = 'security';
|
||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
@@ -54,7 +65,7 @@ export class SecurityService {
|
||||
public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> {
|
||||
try {
|
||||
// Try to get from cache first
|
||||
const cacheKey = this.getCacheKey(projectId, 'rates');
|
||||
const cacheKey = Keys.Security.rates(projectId);
|
||||
const cached = await redis.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
@@ -107,9 +118,31 @@ export class SecurityService {
|
||||
// Get current security status
|
||||
const status = await this.getSecurityStatus(projectId);
|
||||
|
||||
// If project should be disabled, disable it
|
||||
if (status.shouldDisable) {
|
||||
// If project should be disabled, disable it (only if auto-disable is enabled)
|
||||
if (status.shouldDisable && AUTO_PROJECT_DISABLE) {
|
||||
await this.disableProject(projectId, status);
|
||||
} else if (status.shouldDisable && !AUTO_PROJECT_DISABLE) {
|
||||
// Log critical violations but don't auto-disable (self-hosted mode)
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {name: true},
|
||||
});
|
||||
|
||||
if (project) {
|
||||
signale.error(
|
||||
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
|
||||
status.violations,
|
||||
);
|
||||
signale.info(
|
||||
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
|
||||
);
|
||||
signale.info(
|
||||
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
|
||||
);
|
||||
|
||||
// Send notification about critical security violations
|
||||
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
|
||||
}
|
||||
} else if (status.warnings.length > 0) {
|
||||
// Log warnings for monitoring
|
||||
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
|
||||
@@ -137,13 +170,36 @@ export class SecurityService {
|
||||
*/
|
||||
public static async invalidateCache(projectId: string): Promise<void> {
|
||||
try {
|
||||
const cacheKey = this.getCacheKey(projectId, 'rates');
|
||||
const cacheKey = Keys.Security.rates(projectId);
|
||||
await redis.del(cacheKey);
|
||||
} catch (error) {
|
||||
signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a member of any disabled project
|
||||
* Users with disabled projects cannot create new projects
|
||||
*/
|
||||
public static async userHasDisabledProject(userId: string): Promise<{
|
||||
hasDisabledProject: boolean;
|
||||
disabledProjectNames: string[];
|
||||
}> {
|
||||
return MembershipService.userHasDisabledProject(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific project is disabled
|
||||
*/
|
||||
public static async isProjectDisabled(projectId: string): Promise<boolean> {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {disabled: true},
|
||||
});
|
||||
|
||||
return project?.disabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a project's security metrics (for admin/dashboard display)
|
||||
*/
|
||||
@@ -167,13 +223,6 @@ export class SecurityService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key for security metrics
|
||||
*/
|
||||
private static getCacheKey(projectId: string, type: 'rates'): string {
|
||||
return `${this.CACHE_PREFIX}:${projectId}:${type}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bounce and complaint rates for a project
|
||||
*/
|
||||
@@ -233,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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -345,6 +426,26 @@ export class SecurityService {
|
||||
|
||||
// Send urgent notification about project suspension
|
||||
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
|
||||
|
||||
// Send email notification to project members
|
||||
try {
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(ProjectDisabledEmail, {
|
||||
projectName: project.name,
|
||||
projectId,
|
||||
violations: status.violations,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Security Risk', template)),
|
||||
);
|
||||
}
|
||||
} catch (emailError) {
|
||||
signale.error(`[SECURITY] Failed to send project disabled email:`, emailError);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
@@ -7,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"
|
||||
@@ -71,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,
|
||||
@@ -89,7 +104,7 @@ export class SegmentService {
|
||||
]);
|
||||
|
||||
return {
|
||||
contacts,
|
||||
data: contacts,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -105,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,
|
||||
},
|
||||
@@ -152,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 = {};
|
||||
|
||||
@@ -167,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);
|
||||
@@ -236,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},
|
||||
@@ -256,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
|
||||
@@ -267,22 +297,128 @@ 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},
|
||||
data: {memberCount},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to update count for segment ${segment.id}:`, error);
|
||||
signale.error(`Failed to update count for segment ${segment.id}:`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -297,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
|
||||
@@ -363,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 {
|
||||
@@ -383,7 +554,7 @@ export class SegmentService {
|
||||
segmentName: segment.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[SEGMENT] Failed to track segment entry event for contact ${contactId}:`, error);
|
||||
signale.error(`[SEGMENT] Failed to track segment entry event for contact ${contactId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,7 +585,7 @@ export class SegmentService {
|
||||
segmentName: segment.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[SEGMENT] Failed to track segment exit event for contact ${contactId}:`, error);
|
||||
signale.error(`[SEGMENT] Failed to track segment exit event for contact ${contactId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,7 +596,7 @@ export class SegmentService {
|
||||
data: {memberCount: matchingContactIds.size},
|
||||
});
|
||||
|
||||
console.log(
|
||||
signale.info(
|
||||
`[SEGMENT] Computed membership for segment ${segmentId}: added ${toAdd.length}, removed ${toRemove.length}, total ${matchingContactIds.size}`,
|
||||
);
|
||||
|
||||
@@ -457,7 +628,7 @@ export class SegmentService {
|
||||
// Handle JSON field paths (e.g., "data.plan")
|
||||
if (field.startsWith('data.')) {
|
||||
const jsonPath = field.substring(5); // Remove "data." prefix
|
||||
return this.buildJsonFieldCondition(jsonPath, operator, value);
|
||||
return this.buildJsonFieldCondition(jsonPath, operator, value, unit);
|
||||
}
|
||||
|
||||
// Handle regular fields
|
||||
@@ -495,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)
|
||||
*/
|
||||
@@ -550,8 +734,10 @@ export class SegmentService {
|
||||
'exists',
|
||||
'notExists',
|
||||
'within',
|
||||
'olderThan',
|
||||
'triggered',
|
||||
'triggeredWithin',
|
||||
'triggeredOlderThan',
|
||||
'notTriggered',
|
||||
];
|
||||
|
||||
@@ -570,7 +756,9 @@ export class SegmentService {
|
||||
'greaterThanOrEqual',
|
||||
'lessThanOrEqual',
|
||||
'within',
|
||||
'olderThan',
|
||||
'triggeredWithin',
|
||||
'triggeredOlderThan',
|
||||
];
|
||||
|
||||
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
||||
@@ -578,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)`);
|
||||
}
|
||||
}
|
||||
@@ -593,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)
|
||||
*/
|
||||
@@ -637,13 +812,38 @@ export class SegmentService {
|
||||
/**
|
||||
* Build condition for JSON fields (stored in contact.data)
|
||||
*/
|
||||
private static buildJsonFieldCondition(jsonPath: string, operator: string, value: unknown): Prisma.ContactWhereInput {
|
||||
private static buildJsonFieldCondition(
|
||||
jsonPath: string,
|
||||
operator: string,
|
||||
value: unknown,
|
||||
unit?: 'days' | 'hours' | 'minutes',
|
||||
): Prisma.ContactWhereInput {
|
||||
const path = jsonPath.split('.');
|
||||
|
||||
switch (operator) {
|
||||
case 'equals':
|
||||
// For date strings, compare only the date portion (ignore time)
|
||||
if (this.isDateString(value)) {
|
||||
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
|
||||
return {
|
||||
AND: [
|
||||
{data: {path, gte: startOfDay as Prisma.InputJsonValue}},
|
||||
{data: {path, lt: startOfNextDay as Prisma.InputJsonValue}},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {data: {path, equals: value as Prisma.InputJsonValue}};
|
||||
case 'notEquals':
|
||||
// For date strings, exclude the entire day (not just exact timestamp)
|
||||
if (this.isDateString(value)) {
|
||||
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
|
||||
return {
|
||||
OR: [
|
||||
{data: {path, lt: startOfDay as Prisma.InputJsonValue}},
|
||||
{data: {path, gte: startOfNextDay as Prisma.InputJsonValue}},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {NOT: {data: {path, equals: value as Prisma.InputJsonValue}}};
|
||||
case 'contains':
|
||||
return {data: {path, string_contains: String(value)}};
|
||||
@@ -669,6 +869,34 @@ export class SegmentService {
|
||||
return {
|
||||
OR: [{data: {path, equals: Prisma.DbNull}}, {data: {path, equals: Prisma.JsonNull}}],
|
||||
};
|
||||
case 'within': {
|
||||
// Note: Requires JSON date fields in ISO 8601 format for proper comparison
|
||||
if (!unit) {
|
||||
throw new HttpException(400, 'Unit is required for "within" operator');
|
||||
}
|
||||
|
||||
// Calculate the "since" date (X time units ago from now)
|
||||
const now = new Date();
|
||||
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||
const since = new Date(now.getTime() - milliseconds);
|
||||
|
||||
// Use ISO string for lexicographic comparison in JSON
|
||||
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
@@ -720,6 +948,28 @@ export class SegmentService {
|
||||
unit?: 'days' | 'hours' | 'minutes',
|
||||
): Prisma.ContactWhereInput {
|
||||
switch (operator) {
|
||||
case 'equals': {
|
||||
// For date fields, compare only the date portion (ignore time)
|
||||
if (this.isDateString(value)) {
|
||||
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
|
||||
return {
|
||||
AND: [{[field]: {gte: new Date(startOfDay)}}, {[field]: {lt: new Date(startOfNextDay)}}],
|
||||
};
|
||||
}
|
||||
// Exact timestamp match if not a date string
|
||||
return {[field]: new Date(value as string | number | Date)};
|
||||
}
|
||||
case 'notEquals': {
|
||||
// For date fields, exclude the entire day (not just exact timestamp)
|
||||
if (this.isDateString(value)) {
|
||||
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
|
||||
return {
|
||||
OR: [{[field]: {lt: new Date(startOfDay)}}, {[field]: {gte: new Date(startOfNextDay)}}],
|
||||
};
|
||||
}
|
||||
// Exclude exact timestamp if not a date string
|
||||
return {NOT: {[field]: new Date(value as string | number | Date)}};
|
||||
}
|
||||
case 'greaterThan':
|
||||
return {[field]: {gt: new Date(value as string | number | Date)}};
|
||||
case 'lessThan':
|
||||
@@ -740,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}`);
|
||||
}
|
||||
@@ -761,6 +1023,41 @@ export class SegmentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a date string in YYYY-MM-DD format
|
||||
*/
|
||||
private static isDateString(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false;
|
||||
// Match YYYY-MM-DD format (with optional time component)
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}(T|$)/;
|
||||
if (!dateRegex.test(value)) return false;
|
||||
// Verify it's a valid date
|
||||
const date = new Date(value);
|
||||
return !isNaN(date.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date range for a date string (start of day to start of next day in UTC)
|
||||
* @param value - Date string in YYYY-MM-DD format
|
||||
* @returns Object with startOfDay and startOfNextDay as ISO strings
|
||||
*/
|
||||
private static getDateRange(value: string): {startOfDay: string; startOfNextDay: string} {
|
||||
// Extract just the date part (YYYY-MM-DD)
|
||||
const dateStr = value.split('T')[0];
|
||||
const startOfDay = `${dateStr}T00:00:00.000Z`;
|
||||
|
||||
if (!dateStr) {
|
||||
throw new HttpException(400, `Invalid date string: ${value}`);
|
||||
}
|
||||
|
||||
// Calculate start of next day
|
||||
const nextDay = new Date(dateStr);
|
||||
nextDay.setUTCDate(nextDay.getUTCDate() + 1);
|
||||
const startOfNextDay = nextDay.toISOString().split('T')[0] + 'T00:00:00.000Z';
|
||||
|
||||
return {startOfDay, startOfNextDay};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build condition for event-based filters
|
||||
* Uses Prisma relations to efficiently query contacts who triggered specific events
|
||||
@@ -804,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 {
|
||||
@@ -878,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,
|
||||
@@ -88,8 +81,8 @@ export class TemplateService {
|
||||
subject: string;
|
||||
body: string;
|
||||
from: string;
|
||||
fromName?: string;
|
||||
replyTo?: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
type?: Template['type'];
|
||||
},
|
||||
): Promise<Template> {
|
||||
@@ -120,8 +113,8 @@ export class TemplateService {
|
||||
subject?: string;
|
||||
body?: string;
|
||||
from?: string;
|
||||
fromName?: string;
|
||||
replyTo?: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
type?: Template['type'];
|
||||
},
|
||||
): Promise<Template> {
|
||||
|
||||
@@ -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('.')}`;
|
||||
}
|
||||
@@ -43,7 +48,7 @@ function getCookieDomain(): string | undefined {
|
||||
}
|
||||
|
||||
export class UserService {
|
||||
public static readonly COOKIE_NAME = 'token';
|
||||
public static readonly COOKIE_NAME = 'next_token';
|
||||
|
||||
public static async id(id: string) {
|
||||
return wrapRedis(Keys.User.id(id), async () => {
|
||||
@@ -52,6 +57,10 @@ export class UserService {
|
||||
}
|
||||
|
||||
public static async email(email: string) {
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return wrapRedis(Keys.User.email(email), async () => {
|
||||
return prisma.user.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -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,22 @@ 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 and validate step config using schema
|
||||
const stepConfig = WorkflowStepConfigSchemas.sendEmail.parse(config);
|
||||
const recipientConfig = stepConfig.recipient || {type: 'CONTACT' as const};
|
||||
|
||||
// Get contact data for variable substitution
|
||||
const contact = execution.contact;
|
||||
const contactData =
|
||||
@@ -543,6 +549,7 @@ export class WorkflowExecutionService {
|
||||
: {};
|
||||
|
||||
const variables = {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
...contactData,
|
||||
...executionContext,
|
||||
@@ -555,10 +562,14 @@ export class WorkflowExecutionService {
|
||||
const renderedSubject = this.renderTemplate(step.template.subject, variables);
|
||||
const renderedBody = this.renderTemplate(step.template.body, variables);
|
||||
|
||||
// Determine recipient email
|
||||
// Schema validation ensures customEmail exists when type is CUSTOM
|
||||
const recipientEmail = recipientConfig.type === 'CUSTOM' ? recipientConfig.customEmail! : contact.email;
|
||||
|
||||
// Send email via EmailService
|
||||
const email = await EmailService.sendWorkflowEmail({
|
||||
projectId: execution.workflow.projectId,
|
||||
contactId: contact.id,
|
||||
contactId: contact.id, // Keep original contact for tracking
|
||||
workflowExecutionId: execution.id,
|
||||
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
||||
templateId: step.template.id,
|
||||
@@ -567,11 +578,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' ? recipientConfig.customEmail : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
emailId: email.id,
|
||||
sentAt: email.createdAt,
|
||||
recipientType: recipientConfig.type,
|
||||
recipientEmail,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -703,7 +718,7 @@ export class WorkflowExecutionService {
|
||||
_stepExecution: WorkflowStepExecution,
|
||||
config: StepConfig,
|
||||
): Promise<StepResult> {
|
||||
const {field, operator, value} = WorkflowStepConfigSchemas.condition.parse(config);
|
||||
const parsed = WorkflowStepConfigSchemas.condition.parse(config);
|
||||
|
||||
// Get the value to evaluate
|
||||
const contact = execution.contact;
|
||||
@@ -719,7 +734,7 @@ export class WorkflowExecutionService {
|
||||
// - data.firstName, data.lastName, etc.
|
||||
// - workflow.* (execution context - alias for event data)
|
||||
// - event.* (event data that triggered the workflow)
|
||||
const actualValue = this.resolveField(field, {
|
||||
const fieldData = {
|
||||
contact: {
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
@@ -727,16 +742,47 @@ export class WorkflowExecutionService {
|
||||
data: contactData,
|
||||
workflow: context,
|
||||
event: context, // Alias for easier access to event data
|
||||
});
|
||||
};
|
||||
|
||||
// Evaluate the condition
|
||||
// Multi-branch mode (switch/case)
|
||||
if ('mode' in parsed && parsed.mode === 'multi') {
|
||||
const actualValue = this.resolveField(parsed.field, fieldData);
|
||||
|
||||
for (const branch of parsed.branches) {
|
||||
if (this.evaluateCondition(actualValue, branch.operator, branch.value)) {
|
||||
return {
|
||||
field: parsed.field,
|
||||
mode: 'multi',
|
||||
matchedBranch: branch.name,
|
||||
actualValue,
|
||||
branch: branch.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No branch matched — use default
|
||||
return {
|
||||
field: parsed.field,
|
||||
mode: 'multi',
|
||||
matchedBranch: 'default',
|
||||
actualValue,
|
||||
branch: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy binary mode (if/else)
|
||||
const field = parsed.field;
|
||||
const operator = 'operator' in parsed ? parsed.operator : 'equals';
|
||||
const value = 'value' in parsed ? parsed.value : undefined;
|
||||
const actualValue = this.resolveField(field, fieldData);
|
||||
const result = this.evaluateCondition(actualValue, operator, value);
|
||||
|
||||
return {
|
||||
field,
|
||||
operator,
|
||||
expectedValue: value,
|
||||
actualValue,
|
||||
// Convert undefined to null so it's preserved in JSON (JSON.stringify removes undefined)
|
||||
actualValue: actualValue === undefined ? null : actualValue,
|
||||
result,
|
||||
branch: result ? 'yes' : 'no',
|
||||
};
|
||||
@@ -866,7 +912,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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -972,7 +1018,15 @@ export class WorkflowExecutionService {
|
||||
* Helper: Resolve field value from object using dot notation
|
||||
*/
|
||||
private static resolveField(field: string, data: Record<string, unknown>): unknown {
|
||||
const parts = field.split('.');
|
||||
// Handle legacy "contact.data.X" format by converting to "data.X"
|
||||
// The UI historically showed examples like "contact.data.plan" but the field structure
|
||||
// has "data" as a top-level key, not nested under "contact"
|
||||
let normalizedField = field;
|
||||
if (field.startsWith('contact.data.')) {
|
||||
normalizedField = field.substring(8); // Remove "contact." prefix, leaving "data.X"
|
||||
}
|
||||
|
||||
const parts = normalizedField.split('.');
|
||||
let value: unknown = data;
|
||||
|
||||
for (const part of parts) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
@@ -9,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 = {
|
||||
@@ -70,7 +55,7 @@ export class WorkflowService {
|
||||
]);
|
||||
|
||||
return {
|
||||
workflows: workflows as Workflow[],
|
||||
data: workflows as Workflow[],
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -209,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
|
||||
@@ -235,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) {
|
||||
@@ -320,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,
|
||||
},
|
||||
});
|
||||
@@ -402,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};
|
||||
@@ -595,7 +617,7 @@ export class WorkflowService {
|
||||
fromStepId: data.fromStepId,
|
||||
condition: {
|
||||
path: ['branch'],
|
||||
equals: conditionObj.branch as Prisma.InputJsonValue,
|
||||
equals: toPrismaJson(conditionObj.branch),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -786,7 +808,7 @@ export class WorkflowService {
|
||||
// Start executing the workflow asynchronously
|
||||
// Don't await - let it run in background
|
||||
WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => {
|
||||
console.error('Error executing workflow:', error);
|
||||
signale.error('Error executing workflow:', error);
|
||||
});
|
||||
|
||||
return execution;
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
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';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {redis} from '../../database/redis';
|
||||
|
||||
// Mock STRIPE_ENABLED and STRIPE_SK for free tier tests
|
||||
vi.mock('../../app/constants.js', async () => {
|
||||
const actual = await vi.importActual('../../app/constants.js');
|
||||
return {
|
||||
...actual,
|
||||
STRIPE_ENABLED: true,
|
||||
STRIPE_SK: 'sk_test_mock_key_for_testing',
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the stripe client to avoid actual Stripe API calls
|
||||
vi.mock('../../app/stripe.js', () => ({
|
||||
stripe: {
|
||||
customers: {
|
||||
retrieve: vi.fn().mockResolvedValue({
|
||||
deleted: false,
|
||||
currency: 'usd',
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BillingLimitService - Critical Enforcement', () => {
|
||||
let projectId: string;
|
||||
let contactId: string;
|
||||
@@ -298,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,8 +1,18 @@
|
||||
import {describe, it, expect, beforeEach} 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';
|
||||
|
||||
// Mock STRIPE_ENABLED for billing limit tests
|
||||
vi.mock('../../app/constants.js', async () => {
|
||||
const actual = await vi.importActual('../../app/constants.js');
|
||||
return {
|
||||
...actual,
|
||||
STRIPE_ENABLED: true,
|
||||
STRIPE_SK: 'sk_test_mock_key_for_testing',
|
||||
};
|
||||
});
|
||||
|
||||
describe('CampaignService', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
@@ -154,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);
|
||||
@@ -167,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -356,4 +366,227 @@ describe('CampaignService', () => {
|
||||
expect(matching).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('should throw error when campaign has no recipients', async () => {
|
||||
// Create campaign with no contacts in project
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
});
|
||||
|
||||
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow('Campaign has no recipients');
|
||||
});
|
||||
|
||||
it('should send campaign successfully when recipients are under billing limit', async () => {
|
||||
// Set billing limit for campaigns
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 100},
|
||||
});
|
||||
|
||||
// Create 5 subscribed contacts
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Create campaign targeting all contacts
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
// Should send successfully (5 recipients < 100 limit)
|
||||
const sentCampaign = await CampaignService.send(projectId, campaign.id);
|
||||
|
||||
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
|
||||
expect(sentCampaign.totalRecipients).toBe(5);
|
||||
});
|
||||
|
||||
it('should throw 403 error when campaign would exceed billing limit', async () => {
|
||||
// Set billing limit for campaigns to 10
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 10},
|
||||
});
|
||||
|
||||
// Create 5 existing campaign emails (usage = 5)
|
||||
const contact = await factories.createContact({projectId, subscribed: true});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: 'CAMPAIGN',
|
||||
});
|
||||
}
|
||||
|
||||
// Create campaign with 10 subscribed contacts (would result in 15 total)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
// Should throw error because 5 + 11 = 16 > 10 limit (11 contacts: 1 from email creation + 10 new)
|
||||
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(/exceed billing limit/i);
|
||||
});
|
||||
|
||||
it('should throw 403 error when scheduled campaign would exceed billing limit', async () => {
|
||||
// Set billing limit for campaigns to 20
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 20},
|
||||
});
|
||||
|
||||
// Create 15 existing campaign emails
|
||||
const contact = await factories.createContact({projectId, subscribed: true});
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: 'CAMPAIGN',
|
||||
});
|
||||
}
|
||||
|
||||
// Create campaign with 10 subscribed contacts (would result in 25 total)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
|
||||
|
||||
// Should throw error when scheduling because 15 + 10 = 25 > 20 limit
|
||||
await expect(CampaignService.send(projectId, campaign.id, scheduledFor)).rejects.toThrow(
|
||||
/Cannot schedule campaign.*exceed billing limit/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should send campaign successfully when billing limit is null (unlimited)', async () => {
|
||||
// Set billing limit to null (unlimited)
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: null},
|
||||
});
|
||||
|
||||
// Create 1000 subscribed contacts
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
// Should send successfully (no limit)
|
||||
const sentCampaign = await CampaignService.send(projectId, campaign.id);
|
||||
|
||||
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
|
||||
expect(sentCampaign.totalRecipients).toBe(1000);
|
||||
});
|
||||
|
||||
it('should allow campaign that exactly reaches billing limit', async () => {
|
||||
// Set billing limit for campaigns to 10
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 10},
|
||||
});
|
||||
|
||||
// Create 5 existing campaign emails
|
||||
const contact = await factories.createContact({projectId, subscribed: true});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: 'CAMPAIGN',
|
||||
});
|
||||
}
|
||||
|
||||
// Create 4 more subscribed contacts (total of 5 with the one above: 1 + 4 = 5)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
// Should send successfully because 5 + 5 = 10 (exactly at limit)
|
||||
const sentCampaign = await CampaignService.send(projectId, campaign.id);
|
||||
|
||||
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
|
||||
expect(sentCampaign.totalRecipients).toBe(5);
|
||||
});
|
||||
|
||||
it('should throw error when campaign has already been sent', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.SENT,
|
||||
});
|
||||
|
||||
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(
|
||||
'Campaign has already been sent or is currently sending',
|
||||
);
|
||||
});
|
||||
|
||||
it('should schedule campaign successfully when recipients are under billing limit', async () => {
|
||||
// Set billing limit for campaigns
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 50},
|
||||
});
|
||||
|
||||
// Create 10 subscribed contacts
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
|
||||
|
||||
// Should schedule successfully (10 recipients < 50 limit)
|
||||
const scheduledCampaign = await CampaignService.send(projectId, campaign.id, scheduledFor);
|
||||
|
||||
expect(scheduledCampaign.status).toBe(CampaignStatus.SCHEDULED);
|
||||
expect(scheduledCampaign.scheduledFor).toEqual(scheduledFor);
|
||||
expect(scheduledCampaign.totalRecipients).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -439,4 +439,266 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
|
||||
expect(unsubscribed?.subscribed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bulk Contact Operations', () => {
|
||||
describe('bulkSubscribe', () => {
|
||||
it('should subscribe multiple unsubscribed contacts', async () => {
|
||||
const contact1 = await factories.createContact({projectId, subscribed: false});
|
||||
const contact2 = await factories.createContact({projectId, subscribed: false});
|
||||
const contact3 = await factories.createContact({projectId, subscribed: false});
|
||||
|
||||
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
|
||||
|
||||
expect(result.updated).toBe(3);
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
|
||||
});
|
||||
|
||||
expect(contacts.every(c => c.subscribed)).toBe(true);
|
||||
});
|
||||
|
||||
it('should only update unsubscribed contacts, not already subscribed ones', async () => {
|
||||
const unsubscribed1 = await factories.createContact({projectId, subscribed: false});
|
||||
const unsubscribed2 = await factories.createContact({projectId, subscribed: false});
|
||||
const alreadySubscribed = await factories.createContact({projectId, subscribed: true});
|
||||
|
||||
const result = await ContactService.bulkSubscribe(projectId, [
|
||||
unsubscribed1.id,
|
||||
unsubscribed2.id,
|
||||
alreadySubscribed.id,
|
||||
]);
|
||||
|
||||
expect(result.updated).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 0 if no contacts need updating', async () => {
|
||||
const contact1 = await factories.createContact({projectId, subscribed: true});
|
||||
const contact2 = await factories.createContact({projectId, subscribed: true});
|
||||
|
||||
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id]);
|
||||
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should only update contacts belonging to the specified project', async () => {
|
||||
const {project: otherProject} = await factories.createUserWithProject();
|
||||
const ownContact = await factories.createContact({projectId, subscribed: false});
|
||||
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: false});
|
||||
|
||||
const result = await ContactService.bulkSubscribe(projectId, [ownContact.id, otherContact.id]);
|
||||
|
||||
expect(result.updated).toBe(1);
|
||||
|
||||
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
|
||||
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
|
||||
|
||||
expect(ownContactAfter?.subscribed).toBe(true);
|
||||
expect(otherContactAfter?.subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty contact IDs array', async () => {
|
||||
const result = await ContactService.bulkSubscribe(projectId, []);
|
||||
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle non-existent contact IDs gracefully', async () => {
|
||||
const result = await ContactService.bulkSubscribe(projectId, ['non-existent-1', 'non-existent-2']);
|
||||
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle large batches efficiently', async () => {
|
||||
const contacts = await Promise.all(
|
||||
Array.from({length: 150}, () => factories.createContact({projectId, subscribed: false})),
|
||||
);
|
||||
const contactIds = contacts.map(c => c.id);
|
||||
|
||||
const result = await ContactService.bulkSubscribe(projectId, contactIds);
|
||||
|
||||
expect(result.updated).toBe(150);
|
||||
|
||||
const updatedContacts = await prisma.contact.findMany({
|
||||
where: {id: {in: contactIds}},
|
||||
});
|
||||
|
||||
expect(updatedContacts.every(c => c.subscribed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUnsubscribe', () => {
|
||||
it('should unsubscribe multiple subscribed contacts', async () => {
|
||||
const contact1 = await factories.createContact({projectId, subscribed: true});
|
||||
const contact2 = await factories.createContact({projectId, subscribed: true});
|
||||
const contact3 = await factories.createContact({projectId, subscribed: true});
|
||||
|
||||
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
|
||||
|
||||
expect(result.updated).toBe(3);
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
|
||||
});
|
||||
|
||||
expect(contacts.every(c => !c.subscribed)).toBe(true);
|
||||
});
|
||||
|
||||
it('should only update subscribed contacts, not already unsubscribed ones', async () => {
|
||||
const subscribed1 = await factories.createContact({projectId, subscribed: true});
|
||||
const subscribed2 = await factories.createContact({projectId, subscribed: true});
|
||||
const alreadyUnsubscribed = await factories.createContact({projectId, subscribed: false});
|
||||
|
||||
const result = await ContactService.bulkUnsubscribe(projectId, [
|
||||
subscribed1.id,
|
||||
subscribed2.id,
|
||||
alreadyUnsubscribed.id,
|
||||
]);
|
||||
|
||||
expect(result.updated).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 0 if no contacts need updating', async () => {
|
||||
const contact1 = await factories.createContact({projectId, subscribed: false});
|
||||
const contact2 = await factories.createContact({projectId, subscribed: false});
|
||||
|
||||
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id]);
|
||||
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should only update contacts belonging to the specified project', async () => {
|
||||
const {project: otherProject} = await factories.createUserWithProject();
|
||||
const ownContact = await factories.createContact({projectId, subscribed: true});
|
||||
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: true});
|
||||
|
||||
const result = await ContactService.bulkUnsubscribe(projectId, [ownContact.id, otherContact.id]);
|
||||
|
||||
expect(result.updated).toBe(1);
|
||||
|
||||
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
|
||||
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
|
||||
|
||||
expect(ownContactAfter?.subscribed).toBe(false);
|
||||
expect(otherContactAfter?.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty contact IDs array', async () => {
|
||||
const result = await ContactService.bulkUnsubscribe(projectId, []);
|
||||
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle non-existent contact IDs gracefully', async () => {
|
||||
const result = await ContactService.bulkUnsubscribe(projectId, ['non-existent-1', 'non-existent-2']);
|
||||
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkDelete', () => {
|
||||
it('should delete multiple contacts', async () => {
|
||||
const contact1 = await factories.createContact({projectId});
|
||||
const contact2 = await factories.createContact({projectId});
|
||||
const contact3 = await factories.createContact({projectId});
|
||||
|
||||
const result = await ContactService.bulkDelete(projectId, [contact1.id, contact2.id, contact3.id]);
|
||||
|
||||
expect(result.deleted).toBe(3);
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
|
||||
});
|
||||
|
||||
expect(contacts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should only delete contacts belonging to the specified project', async () => {
|
||||
const {project: otherProject} = await factories.createUserWithProject();
|
||||
const ownContact = await factories.createContact({projectId});
|
||||
const otherContact = await factories.createContact({projectId: otherProject.id});
|
||||
|
||||
const result = await ContactService.bulkDelete(projectId, [ownContact.id, otherContact.id]);
|
||||
|
||||
expect(result.deleted).toBe(1);
|
||||
|
||||
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
|
||||
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
|
||||
|
||||
expect(ownContactAfter).toBeNull();
|
||||
expect(otherContactAfter).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty contact IDs array', async () => {
|
||||
const result = await ContactService.bulkDelete(projectId, []);
|
||||
|
||||
expect(result.deleted).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle non-existent contact IDs gracefully', async () => {
|
||||
const result = await ContactService.bulkDelete(projectId, ['non-existent-1', 'non-existent-2']);
|
||||
|
||||
expect(result.deleted).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle large batches efficiently', async () => {
|
||||
const contacts = await Promise.all(Array.from({length: 200}, () => factories.createContact({projectId})));
|
||||
const contactIds = contacts.map(c => c.id);
|
||||
|
||||
const result = await ContactService.bulkDelete(projectId, contactIds);
|
||||
|
||||
expect(result.deleted).toBe(200);
|
||||
|
||||
const remainingContacts = await prisma.contact.findMany({
|
||||
where: {id: {in: contactIds}},
|
||||
});
|
||||
|
||||
expect(remainingContacts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should delete both subscribed and unsubscribed contacts', async () => {
|
||||
const subscribed = await factories.createContact({projectId, subscribed: true});
|
||||
const unsubscribed = await factories.createContact({projectId, subscribed: false});
|
||||
|
||||
const result = await ContactService.bulkDelete(projectId, [subscribed.id, unsubscribed.id]);
|
||||
|
||||
expect(result.deleted).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle partial matches (some exist, some do not)', async () => {
|
||||
const existingContact = await factories.createContact({projectId});
|
||||
|
||||
const result = await ContactService.bulkDelete(projectId, [existingContact.id, 'non-existent-id']);
|
||||
|
||||
expect(result.deleted).toBe(1);
|
||||
|
||||
const contact = await prisma.contact.findUnique({where: {id: existingContact.id}});
|
||||
expect(contact).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bulk Operations - Project Isolation', () => {
|
||||
it('should never leak contacts between projects in bulk operations', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {project: project2} = await factories.createUserWithProject();
|
||||
|
||||
const p1Contact1 = await factories.createContact({projectId: project1.id, subscribed: false});
|
||||
const p1Contact2 = await factories.createContact({projectId: project1.id, subscribed: false});
|
||||
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
|
||||
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
|
||||
|
||||
await ContactService.bulkSubscribe(project1.id, [p1Contact1.id, p1Contact2.id, p2Contact1.id, p2Contact2.id]);
|
||||
|
||||
const p1ContactsAfter = await prisma.contact.findMany({
|
||||
where: {projectId: project1.id},
|
||||
});
|
||||
const p2ContactsAfter = await prisma.contact.findMany({
|
||||
where: {projectId: project2.id},
|
||||
});
|
||||
|
||||
expect(p1ContactsAfter.every(c => c.subscribed)).toBe(true);
|
||||
expect(p2ContactsAfter.every(c => !c.subscribed)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('sender@unregistered.com', project.id),
|
||||
).rejects.toThrow(HttpException);
|
||||
await expect(DomainService.verifyEmailDomain('sender@unregistered.com', project.id)).rejects.toThrow(
|
||||
HttpException,
|
||||
);
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('sender@unregistered.com', project.id),
|
||||
).rejects.toThrow(/not registered/i);
|
||||
await expect(DomainService.verifyEmailDomain('sender@unregistered.com', project.id)).rejects.toThrow(
|
||||
/not registered/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when domain belongs to different project', async () => {
|
||||
@@ -141,13 +139,11 @@ describe('DomainService', () => {
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('sender@project1.com', project2.id),
|
||||
).rejects.toThrow(HttpException);
|
||||
await expect(DomainService.verifyEmailDomain('sender@project1.com', project2.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('sender@project1.com', project2.id),
|
||||
).rejects.toThrow(/belongs to a different project/i);
|
||||
await expect(DomainService.verifyEmailDomain('sender@project1.com', project2.id)).rejects.toThrow(
|
||||
/belongs to a different project/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when domain is not verified', async () => {
|
||||
@@ -155,13 +151,11 @@ describe('DomainService', () => {
|
||||
|
||||
await DomainService.addDomain(project.id, 'unverified.com');
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('sender@unverified.com', project.id),
|
||||
).rejects.toThrow(HttpException);
|
||||
await expect(DomainService.verifyEmailDomain('sender@unverified.com', project.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('sender@unverified.com', project.id),
|
||||
).rejects.toThrow(/not verified/i);
|
||||
await expect(DomainService.verifyEmailDomain('sender@unverified.com', 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('sender@other.example.com', project.id),
|
||||
).rejects.toThrow(/not registered/i);
|
||||
await expect(DomainService.verifyEmailDomain('sender@other.example.com', project.id)).rejects.toThrow(
|
||||
/not registered/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle email with no @ sign', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
);
|
||||
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 {describe, it, expect, beforeEach, 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(),
|
||||
}));
|
||||
@@ -669,7 +690,6 @@ describe('EmailService', () => {
|
||||
// ========================================
|
||||
describe('Attachment Schema Validation', () => {
|
||||
it('should validate attachment count limit (max 10)', () => {
|
||||
|
||||
const tooManyAttachments = Array.from({length: 11}, (_, i) => ({
|
||||
filename: `file${i}.txt`,
|
||||
content: Buffer.from('content').toString('base64'),
|
||||
@@ -690,7 +710,6 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should validate attachment size limit (10MB total)', () => {
|
||||
|
||||
// Exceeds ~13.3M base64 chars limit
|
||||
const largeContent = 'A'.repeat(14000000);
|
||||
|
||||
@@ -711,11 +730,11 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should accept attachments within size limit', () => {
|
||||
|
||||
const validContent = Buffer.from('Small file content').toString('base64');
|
||||
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
from: 'test@example.com',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
attachments: [
|
||||
@@ -731,7 +750,6 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should reject attachment with missing required fields', () => {
|
||||
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
@@ -748,7 +766,6 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should reject attachment with empty filename', () => {
|
||||
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
@@ -766,7 +783,6 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should reject attachment with filename exceeding 255 chars', () => {
|
||||
|
||||
const tooLongFilename = 'a'.repeat(256) + '.pdf';
|
||||
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
@@ -786,18 +802,12 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should accept valid attachment with various content types', () => {
|
||||
|
||||
const contentTypes = [
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'text/plain',
|
||||
'application/zip',
|
||||
];
|
||||
const contentTypes = ['application/pdf', 'image/png', 'image/jpeg', 'text/plain', 'application/zip'];
|
||||
|
||||
for (const contentType of contentTypes) {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
from: 'test@example.com',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
attachments: [
|
||||
@@ -812,5 +822,204 @@ describe('EmailService', () => {
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept inline attachment with contentId', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
from: 'test@example.com',
|
||||
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: 'test@example.com',
|
||||
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: 'test@example.com',
|
||||
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: 'sender@example.com'},
|
||||
to: ['recipient@example.com'],
|
||||
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: 'sender@example.com'},
|
||||
to: ['recipient@example.com'],
|
||||
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: 'sender@example.com'},
|
||||
to: ['recipient@example.com'],
|
||||
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}--`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {WorkflowExecutionStatus, WorkflowTriggerType} from '@plunk/db';
|
||||
import {EventService} from '../EventService';
|
||||
import {Keys} from '../keys';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Mock Redis for caching tests - must be inline to avoid hoisting issues
|
||||
@@ -503,7 +504,7 @@ describe('EventService', () => {
|
||||
const {redis} = await import('../../database/redis');
|
||||
|
||||
// Set cache
|
||||
const cacheKey = `workflows:enabled:${projectId}`;
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
await redis.set(cacheKey, JSON.stringify([{id: 'test'}]));
|
||||
|
||||
// Verify cache exists
|
||||
@@ -852,4 +853,90 @@ describe('EventService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// RESERVED EVENT VALIDATION
|
||||
// ========================================
|
||||
describe('isReservedEvent', () => {
|
||||
describe('Email events (email.*)', () => {
|
||||
it('should identify email.sent as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.sent')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify email.delivery as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.delivery')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify email.open as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.open')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify email.click as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.click')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify email.bounce as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.bounce')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify email.complaint as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.complaint')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify any email.* pattern as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.custom')).toBe(true);
|
||||
expect(EventService.isReservedEvent('email.anything')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contact events', () => {
|
||||
it('should identify contact.subscribed as reserved', () => {
|
||||
expect(EventService.isReservedEvent('contact.subscribed')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify contact.unsubscribed as reserved', () => {
|
||||
expect(EventService.isReservedEvent('contact.unsubscribed')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not identify other contact.* events as reserved', () => {
|
||||
expect(EventService.isReservedEvent('contact.created')).toBe(false);
|
||||
expect(EventService.isReservedEvent('contact.updated')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Segment events (segment.*.entry, segment.*.exit)', () => {
|
||||
it('should identify segment.*.entry as reserved', () => {
|
||||
expect(EventService.isReservedEvent('segment.vip-users.entry')).toBe(true);
|
||||
expect(EventService.isReservedEvent('segment.premium.entry')).toBe(true);
|
||||
expect(EventService.isReservedEvent('segment.active-subscribers.entry')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify segment.*.exit as reserved', () => {
|
||||
expect(EventService.isReservedEvent('segment.vip-users.exit')).toBe(true);
|
||||
expect(EventService.isReservedEvent('segment.premium.exit')).toBe(true);
|
||||
expect(EventService.isReservedEvent('segment.active-subscribers.exit')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not identify other segment.* events as reserved', () => {
|
||||
expect(EventService.isReservedEvent('segment.created')).toBe(false);
|
||||
expect(EventService.isReservedEvent('segment.vip-users.updated')).toBe(false);
|
||||
expect(EventService.isReservedEvent('segment.premium')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-reserved events', () => {
|
||||
it('should not identify custom user events as reserved', () => {
|
||||
expect(EventService.isReservedEvent('user.signup')).toBe(false);
|
||||
expect(EventService.isReservedEvent('purchase.completed')).toBe(false);
|
||||
expect(EventService.isReservedEvent('order.placed')).toBe(false);
|
||||
expect(EventService.isReservedEvent('custom.event')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not identify events with similar prefixes as reserved', () => {
|
||||
expect(EventService.isReservedEvent('emails.sent')).toBe(false);
|
||||
expect(EventService.isReservedEvent('contacts.subscribed')).toBe(false);
|
||||
expect(EventService.isReservedEvent('segments.entry')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 () => {
|
||||
|
||||
@@ -722,4 +722,260 @@ describe('Workflow CONDITION Step - Comprehensive Operator Tests', () => {
|
||||
expect(branch).toBe('yes');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// MULTI-BRANCH CONDITIONS (switch/case)
|
||||
// ========================================
|
||||
describe('Multi-Branch Conditions', () => {
|
||||
/**
|
||||
* Helper to create a workflow with a multi-branch condition step
|
||||
* Creates one exit step per branch + one for default
|
||||
*/
|
||||
async function createMultiBranchWorkflow(
|
||||
contactData: Record<string, unknown>,
|
||||
conditionConfig: Record<string, unknown>,
|
||||
) {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
data: contactData,
|
||||
});
|
||||
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
|
||||
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
|
||||
});
|
||||
|
||||
const conditionStep = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Multi Condition',
|
||||
position: {x: 100, y: 0},
|
||||
config: conditionConfig,
|
||||
},
|
||||
});
|
||||
|
||||
// Create exit steps for each branch + default
|
||||
const branches = conditionConfig.branches as Array<{id: string; name: string; operator: string; value?: unknown}>;
|
||||
const branchExits: Record<string, string> = {};
|
||||
for (let i = 0; i < branches.length; i++) {
|
||||
const branch = branches[i]!;
|
||||
const exitStep = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: `${branch.name} Path`,
|
||||
position: {x: 200, y: i * 100},
|
||||
config: {reason: branch.name},
|
||||
},
|
||||
});
|
||||
branchExits[branch.id] = exitStep.id;
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: exitStep.id,
|
||||
condition: {branch: branch.id},
|
||||
priority: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create default exit
|
||||
const defaultExit = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Default Path',
|
||||
position: {x: 200, y: branches.length * 100},
|
||||
config: {reason: 'default'},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: defaultExit.id,
|
||||
condition: {branch: 'default'},
|
||||
priority: branches.length,
|
||||
},
|
||||
});
|
||||
|
||||
// Connect trigger to condition
|
||||
await prisma.workflowTransition.create({
|
||||
data: {fromStepId: triggerStep.id, toStepId: conditionStep.id},
|
||||
});
|
||||
|
||||
const execution = await prisma.workflowExecution.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
},
|
||||
});
|
||||
|
||||
return {execution, triggerStep, conditionStep, contact, branchExits, defaultExitId: defaultExit.id};
|
||||
}
|
||||
|
||||
const planBranches = {
|
||||
mode: 'multi' as const,
|
||||
field: 'data.plan',
|
||||
branches: [
|
||||
{id: 'br-premium', name: 'Premium', operator: 'equals', value: 'premium'},
|
||||
{id: 'br-free', name: 'Free', operator: 'equals', value: 'free'},
|
||||
{id: 'br-enterprise', name: 'Enterprise', operator: 'equals', value: 'enterprise'},
|
||||
],
|
||||
};
|
||||
|
||||
it('should match the first matching branch', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'premium'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-premium');
|
||||
});
|
||||
|
||||
it('should match a non-first branch correctly', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'enterprise'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-enterprise');
|
||||
});
|
||||
|
||||
it('should fall through to default when no branch matches', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'starter'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('default');
|
||||
});
|
||||
|
||||
it('should fall through to default when field is missing', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{other: 'value'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('default');
|
||||
});
|
||||
|
||||
it('should support mixed operators across branches', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{score: 85},
|
||||
{
|
||||
mode: 'multi',
|
||||
field: 'data.score',
|
||||
branches: [
|
||||
{id: 'br-high', name: 'High', operator: 'greaterThanOrEqual', value: 90},
|
||||
{id: 'br-medium', name: 'Medium', operator: 'greaterThanOrEqual', value: 70},
|
||||
{id: 'br-low', name: 'Low', operator: 'lessThan', value: 70},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
// 85 >= 90 is false, 85 >= 70 is true → should match "Medium"
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-medium');
|
||||
});
|
||||
|
||||
it('should respect branch evaluation order (first match wins)', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'premium'},
|
||||
{
|
||||
mode: 'multi',
|
||||
field: 'data.plan',
|
||||
branches: [
|
||||
{id: 'br-first', name: 'First', operator: 'contains', value: 'prem'},
|
||||
{id: 'br-second', name: 'Second', operator: 'equals', value: 'premium'},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
// Both match, but first one should win
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-first');
|
||||
});
|
||||
|
||||
it('should support exists/notExists operators in branches', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'premium'},
|
||||
{
|
||||
mode: 'multi',
|
||||
field: 'data.plan',
|
||||
branches: [
|
||||
{id: 'br-has-plan', name: 'Has Plan', operator: 'exists'},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-has-plan');
|
||||
});
|
||||
|
||||
it('should route to correct exit step via transitions', async () => {
|
||||
const {execution, triggerStep, branchExits, defaultExitId} = await createMultiBranchWorkflow(
|
||||
{plan: 'free'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
|
||||
// Check the execution completed and ended at the correct exit step
|
||||
const completedExecution = await prisma.workflowExecution.findUnique({
|
||||
where: {id: execution.id},
|
||||
});
|
||||
|
||||
// Workflow completed after reaching exit step
|
||||
expect(['EXITED', 'COMPLETED']).toContain(completedExecution?.status);
|
||||
|
||||
// Verify it went through the "free" branch exit, not default
|
||||
const exitStepExecution = await prisma.workflowStepExecution.findFirst({
|
||||
where: {
|
||||
executionId: execution.id,
|
||||
stepId: branchExits['br-free'],
|
||||
},
|
||||
});
|
||||
expect(exitStepExecution).not.toBeNull();
|
||||
|
||||
// Verify it did NOT go through the default exit
|
||||
const defaultStepExecution = await prisma.workflowStepExecution.findFirst({
|
||||
where: {
|
||||
executionId: execution.id,
|
||||
stepId: defaultExitId,
|
||||
},
|
||||
});
|
||||
expect(defaultStepExecution).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: 'hello@example.com',
|
||||
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: 'team@plunk.com',
|
||||
} 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: 'hello@example.com',
|
||||
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';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
|
||||
import {WorkflowService} from '../WorkflowService';
|
||||
import {Keys} from '../keys';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Mock Redis for caching tests - must be inline to avoid hoisting issues
|
||||
@@ -130,7 +131,7 @@ describe('WorkflowService', () => {
|
||||
|
||||
it('should invalidate cache when creating enabled workflow', async () => {
|
||||
const {redis} = await import('../../database/redis');
|
||||
const cacheKey = `workflows:enabled:${projectId}`;
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
|
||||
// Set cache
|
||||
await redis.set(cacheKey, JSON.stringify([{id: 'old'}]));
|
||||
@@ -204,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);
|
||||
});
|
||||
@@ -217,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 () => {
|
||||
@@ -233,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);
|
||||
@@ -286,7 +287,7 @@ describe('WorkflowService', () => {
|
||||
|
||||
it('should invalidate cache when enabling workflow', async () => {
|
||||
const {redis} = await import('../../database/redis');
|
||||
const cacheKey = `workflows:enabled:${projectId}`;
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
|
||||
const workflow = await factories.createWorkflow({
|
||||
projectId,
|
||||
@@ -326,7 +327,7 @@ describe('WorkflowService', () => {
|
||||
|
||||
it('should invalidate cache when deleting enabled workflow', async () => {
|
||||
const {redis} = await import('../../database/redis');
|
||||
const cacheKey = `workflows:enabled:${projectId}`;
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
|
||||
const workflow = await factories.createWorkflow({
|
||||
projectId,
|
||||
|
||||
@@ -6,6 +6,18 @@ export const Keys = {
|
||||
email(email: string): string {
|
||||
return `account:${email}`;
|
||||
},
|
||||
emailVerificationToken(token: string): string {
|
||||
return `auth:email_verification:${token}`;
|
||||
},
|
||||
passwordResetToken(token: string): string {
|
||||
return `auth:password_reset:${token}`;
|
||||
},
|
||||
emailVerificationRateLimit(userId: string): string {
|
||||
return `auth:email_verification_rate:${userId}`;
|
||||
},
|
||||
passwordResetRateLimit(email: string): string {
|
||||
return `auth:password_reset_rate:${email}`;
|
||||
},
|
||||
},
|
||||
Domain: {
|
||||
id(id: string): string {
|
||||
@@ -14,5 +26,73 @@ 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 {
|
||||
return `billing:usage:${projectId}:${sourceType}:${year}-${month}`;
|
||||
},
|
||||
warningEmail(projectId: string, sourceType: string, year: number, month: string): string {
|
||||
return `billing:warning_email:${projectId}:${sourceType}:${year}-${month}`;
|
||||
},
|
||||
limitEmail(projectId: string, sourceType: string, year: number, month: string): string {
|
||||
return `billing:limit_email:${projectId}:${sourceType}:${year}-${month}`;
|
||||
},
|
||||
},
|
||||
Security: {
|
||||
rates(projectId: string): string {
|
||||
return `security:${projectId}:rates`;
|
||||
},
|
||||
},
|
||||
Activity: {
|
||||
stats(projectId: string, startTime: number | string, endTime: number | string): string {
|
||||
return `activity:stats:${projectId}:${startTime}:${endTime}`;
|
||||
},
|
||||
},
|
||||
Analytics: {
|
||||
timeseries(projectId: string, startDate: string, endDate: string): string {
|
||||
return `analytics:timeseries:${projectId}:${startDate}:${endDate}`;
|
||||
},
|
||||
campaignStats(projectId: string, startDate: string, endDate: string): string {
|
||||
return `analytics:campaignStats:${projectId}:${startDate}:${endDate}`;
|
||||
},
|
||||
topEvents(projectId: string, limit: number, startDate: string, endDate: string): string {
|
||||
return `analytics:topEvents:${projectId}:${limit}:${startDate}:${endDate}`;
|
||||
},
|
||||
},
|
||||
Workflow: {
|
||||
enabled(projectId: string): string {
|
||||
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;
|
||||
|
||||
@@ -34,8 +34,8 @@ export function buildEmailFieldsUpdate(data: {
|
||||
subject?: string;
|
||||
body?: string;
|
||||
from?: string;
|
||||
fromName?: string;
|
||||
replyTo?: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
}): Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput {
|
||||
return buildUpdateData(data) as Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.swyp.be',
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.useplunk.com',
|
||||
generateRobotsTxt: true,
|
||||
};
|
||||
|
||||
@@ -14,11 +14,23 @@
|
||||
"@plunk/db": "*",
|
||||
"@plunk/shared": "*",
|
||||
"@plunk/ui": "*",
|
||||
"@tiptap/core": "^3.11.0",
|
||||
"@tiptap/extension-color": "^3.11.0",
|
||||
"@tiptap/extension-image": "^3.11.0",
|
||||
"@tiptap/extension-link": "^3.11.0",
|
||||
"@tiptap/extension-placeholder": "^3.11.0",
|
||||
"@tiptap/extension-text-align": "^3.11.0",
|
||||
"@tiptap/extension-text-style": "^3.11.0",
|
||||
"@tiptap/extension-underline": "^3.11.0",
|
||||
"@tiptap/pm": "^3.11.0",
|
||||
"@tiptap/react": "^3.11.0",
|
||||
"@tiptap/starter-kit": "^3.11.0",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.0.7",
|
||||
"next": "^16.1.7",
|
||||
"next-seo": "^6.6.0",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import {motion} from 'framer-motion';
|
||||
import React, {useState} from 'react';
|
||||
import {Check, Copy} from 'lucide-react';
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
showCopy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable code block component with syntax highlighting styling and copy functionality
|
||||
*/
|
||||
export function CodeBlock({code, language = 'javascript', title, showCopy = true}: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'group relative overflow-hidden rounded-xl border border-neutral-200 bg-neutral-900 w-full max-w-full'}
|
||||
>
|
||||
{(title || showCopy) && (
|
||||
<div className={'flex items-center justify-between border-b border-neutral-800 bg-neutral-900 px-6 py-3'}>
|
||||
{title && <span className={'text-sm font-medium text-neutral-400'}>{title}</span>}
|
||||
{!title && <span className={'text-xs font-medium text-neutral-500 uppercase'}>{language}</span>}
|
||||
{showCopy && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={
|
||||
'flex items-center gap-2 rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-1.5 text-xs font-medium text-neutral-300 transition hover:bg-neutral-700 hover:text-white'
|
||||
}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<pre className={'overflow-x-auto p-6 text-neutral-100 w-full max-w-full'} style={{boxSizing: 'border-box'}}>
|
||||
<code className={'font-mono text-sm leading-relaxed'} style={{whiteSpace: 'pre-wrap', wordBreak: 'break-word'}}>
|
||||
{code}
|
||||
</code>
|
||||
</pre>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {Check, X} from 'lucide-react';
|
||||
import {motion} from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
export interface ComparisonRow {
|
||||
feature: string;
|
||||
plunk: boolean | string;
|
||||
competitor: boolean | string;
|
||||
}
|
||||
|
||||
interface ComparisonTableProps {
|
||||
competitorName: string;
|
||||
rows: ComparisonRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable comparison table component for competitor pages
|
||||
*/
|
||||
export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
|
||||
return (
|
||||
<div className={'overflow-hidden rounded-xl border border-neutral-200'}>
|
||||
{/* Header */}
|
||||
<div className={'grid grid-cols-3 gap-px bg-neutral-200'}>
|
||||
<div className={'bg-white p-6'}>
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Feature</span>
|
||||
</div>
|
||||
<div className={'bg-white p-6 text-center'}>
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk</span>
|
||||
</div>
|
||||
<div className={'bg-white p-6 text-center'}>
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>{competitorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className={'grid gap-px bg-neutral-200'}>
|
||||
{rows.map((row, index) => (
|
||||
<motion.div
|
||||
key={row.feature}
|
||||
initial={{opacity: 0, y: 10}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'grid grid-cols-3 gap-px bg-neutral-200'}
|
||||
>
|
||||
<div className={'bg-white p-6'}>
|
||||
<span className={'text-sm text-neutral-600'}>{row.feature}</span>
|
||||
</div>
|
||||
<div className={'bg-white p-6'}>
|
||||
<div className={'flex justify-center'}>
|
||||
{typeof row.plunk === 'boolean' ? (
|
||||
row.plunk ? (
|
||||
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
|
||||
) : (
|
||||
<X className="h-5 w-5 text-neutral-400" strokeWidth={2} />
|
||||
)
|
||||
) : (
|
||||
<span className={'text-sm text-neutral-900'}>{row.plunk}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={'bg-white p-6'}>
|
||||
<div className={'flex justify-center'}>
|
||||
{typeof row.competitor === 'boolean' ? (
|
||||
row.competitor ? (
|
||||
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
|
||||
) : (
|
||||
<X className="h-5 w-5 text-neutral-400" strokeWidth={2} />
|
||||
)
|
||||
) : (
|
||||
<span className={'text-sm text-neutral-900'}>{row.competitor}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {motion} from 'framer-motion';
|
||||
import Script from 'next/script';
|
||||
import React from 'react';
|
||||
|
||||
export interface FAQ {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
interface FAQSectionProps {
|
||||
faqs: FAQ[];
|
||||
schemaId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable FAQ section component with structured data support
|
||||
*/
|
||||
export function FAQSection({faqs, schemaId = 'faq-schema'}: FAQSectionProps) {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
id={schemaId}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
'mainEntity': faqs.map(faq => ({
|
||||
'@type': 'Question',
|
||||
'name': faq.question,
|
||||
'acceptedAnswer': {
|
||||
'@type': 'Answer',
|
||||
'text': faq.answer,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className={'py-32'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-4xl'}
|
||||
>
|
||||
<h2 className={'mb-16 text-center text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
|
||||
<div className={'space-y-8'}>
|
||||
{faqs.map((faq, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'border-b border-neutral-200 pb-8 last:border-b-0'}
|
||||
>
|
||||
<h3 className={'text-xl font-semibold text-neutral-900'}>{faq.question}</h3>
|
||||
<p className={'mt-4 leading-relaxed text-neutral-600'}>{faq.answer}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,12 +13,12 @@ export default function Footer() {
|
||||
<div className="mx-auto max-w-7xl px-8 py-20 xl:px-0">
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
{/* Logo and description */}
|
||||
<div className="space-y-6 lg:col-span-4">
|
||||
<div className="space-y-6 lg:col-span-3">
|
||||
<div className={'relative h-8 w-8'}>
|
||||
<Image src={logo} alt={'Plunk logo'} fill className={'object-contain'} />
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-neutral-600">
|
||||
Open-source email automation platform that scales
|
||||
Open-source email platform for transactional, marketing, and automation. EU-hosted, GDPR compliant.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
@@ -57,7 +57,7 @@ export default function Footer() {
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="grid grid-cols-2 gap-8 lg:col-span-8 lg:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-8 lg:col-span-9 lg:grid-cols-5">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Product</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
@@ -66,18 +66,96 @@ export default function Footer() {
|
||||
Pricing
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={WIKI_URI} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Documentation
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Guides
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/tools'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Tools
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/made-by-humans'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Made by humans
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Features</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
||||
>
|
||||
Documentation
|
||||
<Link href={'/features/email-editor'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Email editor
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/features/workflows'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Workflows
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/features/segments'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Segments
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/features/smtp'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
SMTP
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/features/inbound-email'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Inbound email
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Compare</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link href={'/vs'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
All comparisons
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/vs/mailchimp'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
vs Mailchimp
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/vs/sendgrid'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
vs SendGrid
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/vs/resend'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
vs Resend
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/vs/brevo'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
vs Brevo
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/vs/mailgun'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
vs Mailgun
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/vs/convertkit'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
vs ConvertKit
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -92,28 +170,18 @@ export default function Footer() {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={'https://github.com/useplunk'}
|
||||
target={'_blank'}
|
||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
||||
>
|
||||
<Link href={'https://github.com/useplunk'} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
GitHub
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={'https://status.useplunk.com'}
|
||||
target={'_blank'}
|
||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
||||
>
|
||||
<Link href={'https://status.useplunk.com'} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Status
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Legal</h3>
|
||||
<h3 className="mt-8 text-sm font-semibold text-neutral-900">Legal</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link href={'/privacy'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
@@ -125,15 +193,59 @@ 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>
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Guides</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link href={'/guides/email-deliverability'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Email deliverability
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides/transactional-vs-marketing-email'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Transactional vs marketing
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides/what-is-dkim'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
What is DKIM?
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides/what-is-spf'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
What is SPF?
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides/what-is-dmarc'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
What is DMARC?
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides/email-open-rate'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Email open rates
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides/email-bounce-rate'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Email bounce rates
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 border-t border-neutral-200 pt-8">
|
||||
<p className="text-sm text-neutral-500">
|
||||
© {new Date().getFullYear()} Plunk. All rights reserved.
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">© {new Date().getFullYear()} Plunk. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -4,175 +4,305 @@ import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import Image from 'next/image';
|
||||
import logo from '../../../public/assets/logo.svg';
|
||||
import {ChevronDown, GitBranch, Inbox, Mail, Server, Users} from 'lucide-react';
|
||||
|
||||
const featuresMenu = [
|
||||
{
|
||||
title: 'Email Editor',
|
||||
description: 'Create beautiful emails with visual or code editing',
|
||||
href: '/features/email-editor',
|
||||
icon: <Mail className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Workflows',
|
||||
description: 'Automate email sequences with triggers and conditions',
|
||||
href: '/features/workflows',
|
||||
icon: <GitBranch className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Inbound Email',
|
||||
description: 'Receive and process incoming emails',
|
||||
href: '/features/inbound-email',
|
||||
icon: <Inbox className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Segments',
|
||||
description: 'Organize contacts with dynamic filtering',
|
||||
href: '/features/segments',
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'SMTP',
|
||||
description: 'Send emails via SMTP or API',
|
||||
href: '/features/smtp',
|
||||
icon: <Server className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Navbar() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [featuresOpen, setFeaturesOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<nav className={'top-0 z-40 mx-auto max-w-7xl px-8 xl:px-0'}>
|
||||
<div className={'z-40 py-6'}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-12">
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
<Link href={'/'} className={'flex items-center gap-x-3'}>
|
||||
<div className={'relative h-8 w-8'}>
|
||||
<Image src={logo} alt={'Plunk logo'} fill className={'object-contain'} />
|
||||
<header className={'sticky top-0 z-40 w-full border-b border-neutral-100 bg-white/95 backdrop-blur-sm'}>
|
||||
<div className={'relative mx-auto max-w-7xl px-8 xl:px-0'}>
|
||||
<div className={'py-5'}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-12">
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
<Link href={'/'} className={'flex items-center gap-x-3'}>
|
||||
<div className={'relative h-8 w-8'}>
|
||||
<Image src={logo} alt={'Plunk logo'} fill className={'object-contain'} />
|
||||
</div>
|
||||
<span className={'sr-only'}>Plunk</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden items-center gap-8 md:flex">
|
||||
<div className={'relative'}>
|
||||
<button
|
||||
onMouseEnter={() => setFeaturesOpen(true)}
|
||||
onMouseLeave={() => setFeaturesOpen(false)}
|
||||
className={
|
||||
'flex items-center gap-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'
|
||||
}
|
||||
>
|
||||
Features
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${featuresOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{featuresOpen && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -10}}
|
||||
transition={{duration: 0.2, ease: [0.22, 1, 0.36, 1]}}
|
||||
onMouseEnter={() => setFeaturesOpen(true)}
|
||||
onMouseLeave={() => setFeaturesOpen(false)}
|
||||
className={
|
||||
'absolute left-0 top-full z-50 mt-2 w-80 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg'
|
||||
}
|
||||
>
|
||||
{featuresMenu.map(feature => (
|
||||
<Link
|
||||
key={feature.href}
|
||||
href={feature.href}
|
||||
className={'flex items-start gap-3 rounded-lg p-3 transition hover:bg-neutral-50'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-neutral-900 text-white'
|
||||
}
|
||||
>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<div className={'text-sm font-semibold text-neutral-900'}>{feature.title}</div>
|
||||
<div className={'mt-0.5 text-xs text-neutral-600'}>{feature.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<span className={'sr-only'}>Plunk</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/made-by-humans'}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
By humans
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/guides'}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
Guides
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
rel={'noreferrer'}
|
||||
className={
|
||||
'flex items-center gap-x-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'
|
||||
}
|
||||
>
|
||||
Docs
|
||||
<svg className={'h-3.5 w-3.5'} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V14.75"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19.25 9.25V4.75H14.75" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 5L11.75 12.25" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden items-center gap-8 md:flex">
|
||||
<Link href={'/made-by-humans'} className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}>
|
||||
By humans
|
||||
</Link>
|
||||
|
||||
<Link href={'/pricing'} className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}>
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
rel={'noreferrer'}
|
||||
className={'flex items-center gap-x-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
<a
|
||||
href={`${DASHBOARD_URI}/auth/login`}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
Docs
|
||||
<svg className={'h-3.5 w-3.5'} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V14.75"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M19.25 9.25V4.75H14.75"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M19 5L11.75 12.25"
|
||||
/>
|
||||
Sign in
|
||||
</a>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Get started
|
||||
</motion.a>
|
||||
</div>
|
||||
|
||||
<div className="-mr-2 flex items-center md:hidden">
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg p-2 text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-neutral-900"
|
||||
aria-controls="mobile-menu"
|
||||
aria-expanded={mobileOpen}
|
||||
>
|
||||
<span className="sr-only">Open main menu</span>
|
||||
|
||||
<svg
|
||||
className={`${mobileOpen ? 'hidden' : 'block'} h-6 w-6`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
<svg
|
||||
className={`${!mobileOpen ? 'hidden' : 'block'} h-6 w-6`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
<a href={`${DASHBOARD_URI}/auth/login`} className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}>
|
||||
Sign in
|
||||
</a>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'}
|
||||
>
|
||||
Get started
|
||||
</motion.a>
|
||||
</div>
|
||||
|
||||
<div className="-mr-2 flex items-center md:hidden">
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg p-2 text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-neutral-900"
|
||||
aria-controls="mobile-menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span className="sr-only">Open main menu</span>
|
||||
|
||||
<svg
|
||||
className={`${mobileOpen ? 'hidden' : 'block'} h-6 w-6`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
className={`${!mobileOpen ? 'hidden' : 'block'} h-6 w-6`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
initial={{height: 0, opacity: 0}}
|
||||
animate={{height: 'auto', opacity: 1}}
|
||||
exit={{height: 0, opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
className="absolute left-0 top-full z-50 mt-2 w-full rounded-lg border border-neutral-200 bg-white shadow-lg backdrop-blur-sm sm:hidden"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
initial={{height: 0, opacity: 0}}
|
||||
animate={{height: 'auto', opacity: 1}}
|
||||
exit={{height: 0, opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
className="space-y-1 p-4"
|
||||
className="absolute left-0 top-full z-50 w-full overflow-hidden border-t border-neutral-100 bg-white shadow-lg md:hidden"
|
||||
>
|
||||
<Link
|
||||
href={'/made-by-humans'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
className="space-y-1 p-4"
|
||||
>
|
||||
By humans
|
||||
</Link>
|
||||
<div className="mb-2">
|
||||
<div className="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-neutral-500">
|
||||
Features
|
||||
</div>
|
||||
{featuresMenu.map(feature => (
|
||||
<Link
|
||||
key={feature.href}
|
||||
href={feature.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-start gap-3 rounded-lg px-4 py-3 transition hover:bg-neutral-100"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-neutral-900 text-white'
|
||||
}
|
||||
>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<div className={'text-sm font-semibold text-neutral-900'}>{feature.title}</div>
|
||||
<div className={'mt-0.5 text-xs text-neutral-600'}>{feature.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
rel={'noreferrer'}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
|
||||
<div className="border-t border-neutral-200 pt-4">
|
||||
<a
|
||||
href={`${DASHBOARD_URI}/auth/login`}
|
||||
<Link
|
||||
href={'/made-by-humans'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
<a
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className="mt-2 block rounded-lg bg-neutral-900 px-4 py-3 text-center text-sm font-semibold text-white transition hover:bg-neutral-800"
|
||||
By humans
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Get started
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/guides'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Guides
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
rel={'noreferrer'}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 pt-4">
|
||||
<a
|
||||
href={`${DASHBOARD_URI}/auth/login`}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
<a
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className="mt-2 block rounded-lg bg-neutral-900 px-4 py-3 text-center text-sm font-semibold text-white transition hover:bg-neutral-800"
|
||||
>
|
||||
Get started
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import {Footer, Navbar} from '../';
|
||||
import {motion} from 'framer-motion';
|
||||
import React, {ReactNode, useLayoutEffect, useState} from 'react';
|
||||
import Link from 'next/link';
|
||||
import {ArticleJsonLd, BreadcrumbJsonLd, NextSeo} from 'next-seo';
|
||||
import {Calendar, Clock} from 'lucide-react';
|
||||
|
||||
interface GuideLayoutProps {
|
||||
title: string;
|
||||
description: string;
|
||||
lastUpdated: string;
|
||||
readTime: string;
|
||||
children: ReactNode;
|
||||
canonical?: string;
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable layout for educational guide pages
|
||||
*/
|
||||
export function GuideLayout({
|
||||
title,
|
||||
description,
|
||||
lastUpdated,
|
||||
readTime,
|
||||
children,
|
||||
canonical,
|
||||
ogImage = 'https://www.useplunk.com/assets/card.png',
|
||||
}: GuideLayoutProps) {
|
||||
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
|
||||
// Extract headings for table of contents
|
||||
useLayoutEffect(() => {
|
||||
const elements = Array.from(document.querySelectorAll('h2, h3'));
|
||||
|
||||
// Generate IDs for headings that don't have them
|
||||
const headingData = elements.map(element => {
|
||||
let id = element.id;
|
||||
if (!id) {
|
||||
// Generate ID from text content
|
||||
id = (element.textContent || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
element.id = id;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
text: element.textContent || '',
|
||||
level: parseInt(element.tagName.substring(1)),
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setHeadings(headingData);
|
||||
|
||||
// Set up intersection observer for active heading
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveId(entry.target.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
{rootMargin: '-100px 0px -80% 0px'},
|
||||
);
|
||||
|
||||
elements.forEach(element => observer.observe(element));
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 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: 3, name: title, item: canonical || ''},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title={`${title} | Plunk`}
|
||||
description={description}
|
||||
canonical={canonical}
|
||||
openGraph={{
|
||||
title: `${title} | Plunk`,
|
||||
description: description,
|
||||
url: canonical,
|
||||
type: 'article',
|
||||
images: [{url: ogImage, alt: title}],
|
||||
article: {
|
||||
publishedTime: lastUpdated,
|
||||
modifiedTime: lastUpdated,
|
||||
authors: ['Plunk'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<ArticleJsonLd
|
||||
type="Article"
|
||||
url={canonical || ''}
|
||||
title={title}
|
||||
images={[ogImage]}
|
||||
datePublished={lastUpdated}
|
||||
dateModified={lastUpdated}
|
||||
authorName="Plunk"
|
||||
description={description}
|
||||
/>
|
||||
|
||||
<BreadcrumbJsonLd itemListElements={breadcrumbItems} />
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className={'mx-auto max-w-7xl px-4 sm:px-8 w-full overflow-x-hidden'}>
|
||||
<div className={'flex flex-col lg:flex-row gap-8 lg:gap-12 py-8 sm:py-16 w-full'}>
|
||||
{/* Main Content */}
|
||||
<article className={'flex-1 max-w-full lg:max-w-4xl w-full'}>
|
||||
{/* Breadcrumbs */}
|
||||
<nav className={'mb-6 sm:mb-8 w-full overflow-x-auto'}>
|
||||
<ol
|
||||
className={
|
||||
'flex flex-wrap items-center gap-1 sm:gap-2 text-xs sm:text-sm text-neutral-600 whitespace-normal'
|
||||
}
|
||||
>
|
||||
<li className={'truncate max-w-[90vw]'}>
|
||||
<Link href="/" className={'hover:text-neutral-900'}>
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
<li>/</li>
|
||||
<li className={'truncate max-w-[90vw]'}>
|
||||
<Link href="/guides" className={'hover:text-neutral-900'}>
|
||||
Guides
|
||||
</Link>
|
||||
</li>
|
||||
<li>/</li>
|
||||
<li className={'text-neutral-900 font-medium truncate max-w-[90vw]'}>{title}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<motion.header
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-8 sm:mb-12 w-full'}
|
||||
>
|
||||
<h1 className={'text-2xl sm:text-4xl font-bold tracking-tight text-neutral-900 break-words max-w-full'}>
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
className={'mt-4 sm:mt-6 text-base sm:text-lg text-neutral-600 leading-relaxed break-words max-w-full'}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={
|
||||
'mt-6 sm:mt-8 flex flex-wrap items-center gap-4 sm:gap-6 text-xs sm:text-sm text-neutral-600'
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
Updated{' '}
|
||||
{new Date(lastUpdated).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{readTime} read</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.header>
|
||||
|
||||
{/* Content */}
|
||||
<div className={'prose prose-neutral max-w-full'} style={{overflowX: 'visible'}}>
|
||||
{/* Responsive table and code block styles */}
|
||||
<style>{`
|
||||
.prose table { display: block; width: 100%; overflow-x: auto; }
|
||||
.prose th, .prose td { white-space: normal; word-break: break-word; }
|
||||
.prose pre, .prose code { max-width: 100vw; overflow-x: auto; word-break: break-word; }
|
||||
.prose img { max-width: 100%; height: auto; }
|
||||
`}</style>
|
||||
{children}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Table of Contents - Desktop Only */}
|
||||
{headings.length > 0 && (
|
||||
<aside className={'hidden lg:block w-64 shrink-0 sticky top-24 self-start'}>
|
||||
<div className={'rounded-xl border border-neutral-200 bg-white p-6 shadow-sm'}>
|
||||
<h2 className={'text-sm font-semibold text-neutral-900 mb-4 uppercase tracking-wide'}>On this page</h2>
|
||||
<nav>
|
||||
<ul className={'space-y-1'}>
|
||||
{headings.map(heading => (
|
||||
<li key={heading.id} className={heading.level === 3 ? 'ml-4 mt-0.5' : 'mt-2 first:mt-0'}>
|
||||
<a
|
||||
href={`#${heading.id}`}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(heading.id);
|
||||
if (element) {
|
||||
const offset = 100; // Account for fixed header
|
||||
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
|
||||
window.scrollTo({
|
||||
top: elementPosition - offset,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`block py-1 border-l-2 -ml-px pl-3 transition-all duration-200 ${
|
||||
heading.level === 2
|
||||
? activeId === heading.id
|
||||
? 'border-neutral-900 text-neutral-900 font-semibold text-sm'
|
||||
: 'border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 font-medium text-sm'
|
||||
: activeId === heading.id
|
||||
? 'border-neutral-700 text-neutral-800 font-medium text-xs'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-200 text-xs'
|
||||
}`}
|
||||
>
|
||||
{heading.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, {ReactNode} from 'react';
|
||||
import {AlertTriangle, CheckCircle2, Info, Lightbulb} from 'lucide-react';
|
||||
|
||||
type InfoBoxType = 'info' | 'warning' | 'tip' | 'success';
|
||||
|
||||
interface InfoBoxProps {
|
||||
type?: InfoBoxType;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const infoBoxConfig: Record<
|
||||
InfoBoxType,
|
||||
{
|
||||
icon: React.ComponentType<{className?: string}>;
|
||||
borderColor: string;
|
||||
bgColor: string;
|
||||
iconColor: string;
|
||||
titleColor: string;
|
||||
}
|
||||
> = {
|
||||
info: {
|
||||
icon: Info,
|
||||
borderColor: 'border-blue-200',
|
||||
bgColor: 'bg-blue-50',
|
||||
iconColor: 'text-blue-600',
|
||||
titleColor: 'text-blue-900',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
borderColor: 'border-amber-200',
|
||||
bgColor: 'bg-amber-50',
|
||||
iconColor: 'text-amber-600',
|
||||
titleColor: 'text-amber-900',
|
||||
},
|
||||
tip: {
|
||||
icon: Lightbulb,
|
||||
borderColor: 'border-purple-200',
|
||||
bgColor: 'bg-purple-50',
|
||||
iconColor: 'text-purple-600',
|
||||
titleColor: 'text-purple-900',
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircle2,
|
||||
borderColor: 'border-green-200',
|
||||
bgColor: 'bg-green-50',
|
||||
iconColor: 'text-green-600',
|
||||
titleColor: 'text-green-900',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* InfoBox component for displaying tips, warnings, notes, and other callouts in guides
|
||||
*/
|
||||
export function InfoBox({type = 'info', title, children, className}: InfoBoxProps) {
|
||||
const config = infoBoxConfig[type];
|
||||
const Icon = config.icon;
|
||||
|
||||
const defaultTitles: Record<InfoBoxType, string> = {
|
||||
info: 'Note',
|
||||
warning: 'Warning',
|
||||
tip: 'Tip',
|
||||
success: 'Success',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border ${config.borderColor} ${config.bgColor} p-6 my-6 ${className || ''}`}>
|
||||
<div className={'flex gap-4'}>
|
||||
<div className={'shrink-0'}>
|
||||
<Icon className={`h-5 w-5 ${config.iconColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
{(title || defaultTitles[type]) && (
|
||||
<h4 className={`not-prose font-semibold ${config.titleColor}`}>{title || defaultTitles[type]}</h4>
|
||||
)}
|
||||
<div className={'text-sm text-neutral-700 leading-relaxed mt-0 prose prose-sm max-w-none'}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export {GuideLayout} from './GuideLayout';
|
||||
export {InfoBox} from './InfoBox';
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from './Navbar';
|
||||
export * from './Footer';
|
||||
export * from './ComparisonTable';
|
||||
export * from './FAQSection';
|
||||
export * from './CodeBlock';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user