Compare commits
347
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52fc7f73ce | ||
|
|
428079e632 | ||
|
|
19ea8b0122 | ||
|
|
6fdc29fc0f | ||
|
|
c91d8c2e29 | ||
|
|
3bb3411994 | ||
|
|
28fe7093f4 | ||
|
|
0803fd7bc4 | ||
|
|
03a058ad48 | ||
|
|
5e62f517fc | ||
|
|
44c86572c2 | ||
|
|
effdfdb6d6 | ||
|
|
c0b2abaeca | ||
|
|
e3395083db | ||
|
|
ae64c2dbb5 | ||
|
|
52cb2b6c77 | ||
|
|
7834b9e7ef | ||
|
|
9e2400c6de | ||
|
|
fb5aa8796a | ||
|
|
284838279d | ||
|
|
2c5a71518d | ||
|
|
a8014cf7cf | ||
|
|
3214f6c42d | ||
|
|
d24259e8d2 | ||
|
|
3343e891bd | ||
|
|
6f99d4190c | ||
|
|
5f990573c1 | ||
|
|
b50b5af763 | ||
|
|
35c9e4c34e | ||
|
|
7b6540a748 | ||
|
|
4b290fd0b7 | ||
|
|
f9d7cf2453 | ||
|
|
ac8aeebfbe | ||
|
|
1675b07694 | ||
|
|
d4f41892cd | ||
|
|
5046c21045 | ||
|
|
9c50e1a7b4 | ||
|
|
33df5632a3 | ||
|
|
3e3117e4a7 | ||
|
|
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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
Developer-founders and indie hackers building SaaS products. They use Plunk to handle transactional and marketing email without the complexity of tools like Mailchimp or Customer.io. They notice tiny details — inconsistent spacing, placeholder text that adds no value, a button that doesn't communicate state. Context: professional environment, desktop-first.
|
||||
|
||||
### Brand Personality
|
||||
Sharp, minimal, confident. The product earns trust by being simple and correct, not by being flashy. Testimonials emphasize "transparent UI", "easy setup", "clean design" — the brand is *care without noise*.
|
||||
|
||||
### Aesthetic Direction
|
||||
Light mode only. Palette: black (`neutral-900`), neutral grays, white. No accent colors. No color for decoration — only for semantics (red = error, green = success). Backgrounds are near-white with subtle texture. Cards use white with a neutral border and light shadow. Typography should feel precise and legible, not editorial. Spacing should feel considered, not generous.
|
||||
|
||||
### Design Principles
|
||||
1. **Every pixel earns its place.** If something doesn't communicate information or provide affordance, remove it.
|
||||
2. **Neutral by default, semantic by exception.** Color is reserved for error/success/warning states, not decoration.
|
||||
3. **Interaction should feel fast.** Loading states communicate exactly what's happening. No silent actions.
|
||||
4. **Developer-grade precision.** Copy is short and direct. Placeholders only appear when they add value. Labels are unambiguous.
|
||||
5. **Consistency is trust.** The same pattern everywhere. One way to show errors. One way to show success. No creative variation in functional UI.
|
||||
@@ -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,10 @@
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailchecker": "^6.0.19",
|
||||
"mailparser": "^3.9.8",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"signale": "^1.4.0",
|
||||
"stripe": "^20.0.0"
|
||||
},
|
||||
@@ -46,6 +49,7 @@
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/helmet": "^4.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/signale": "^1.4.7",
|
||||
|
||||
@@ -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 {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db';
|
||||
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import 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 {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, subject, body, from, fromName, replyTo, type, 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');
|
||||
}
|
||||
@@ -44,6 +42,7 @@ export class Campaigns {
|
||||
from,
|
||||
fromName,
|
||||
replyTo,
|
||||
type,
|
||||
audienceType,
|
||||
audienceCondition,
|
||||
segmentId,
|
||||
@@ -60,10 +59,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 +78,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 +86,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,12 +105,12 @@ 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 {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
|
||||
req.body;
|
||||
|
||||
// Validate audience-specific fields if audienceType is being updated
|
||||
@@ -142,6 +135,7 @@ export class Campaigns {
|
||||
from,
|
||||
fromName,
|
||||
replyTo,
|
||||
type: type as TemplateType | undefined,
|
||||
audienceType,
|
||||
audienceCondition,
|
||||
segmentId,
|
||||
@@ -158,11 +152,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 +171,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 +191,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 +222,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 +242,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 +261,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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import {DomainSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {redis} from '../database/redis.js';
|
||||
import {NotFound} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {isAuthenticated} from '../middleware/auth.js';
|
||||
import {NotAllowed, NotFound} from '../exceptions/index.js';
|
||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('domains')
|
||||
@@ -17,23 +17,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 +35,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,18 +46,14 @@ 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission');
|
||||
// Block domain changes on disabled projects
|
||||
const isDisabled = await SecurityService.isProjectDisabled(projectId);
|
||||
if (isDisabled) {
|
||||
throw new NotAllowed(
|
||||
'Cannot add domains to a disabled project. Please contact support to resolve security violations before making changes.',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if domain is already linked to another project
|
||||
@@ -104,10 +91,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 +104,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 +119,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,18 +132,14 @@ 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Domain not found or you do not have permission');
|
||||
// Block domain changes on disabled projects
|
||||
const isDisabled = await SecurityService.isProjectDisabled(domain.projectId);
|
||||
if (isDisabled) {
|
||||
throw new NotAllowed(
|
||||
'Cannot remove domains from a disabled project. Please contact support to resolve security violations before making changes.',
|
||||
);
|
||||
}
|
||||
|
||||
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,14 +1,22 @@
|
||||
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 {simpleParser} from 'mailparser';
|
||||
import signale from 'signale';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
||||
import {ProjectDisabledPaymentEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import React from 'react';
|
||||
|
||||
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -21,7 +29,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 +39,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 +79,6 @@ export class Webhooks {
|
||||
return res.status(200).json({
|
||||
success: false,
|
||||
message: 'Failed to confirm subscription',
|
||||
subscribeURL: req.body.SubscribeURL,
|
||||
});
|
||||
}
|
||||
} catch (confirmError) {
|
||||
@@ -54,7 +86,6 @@ export class Webhooks {
|
||||
return res.status(200).json({
|
||||
success: false,
|
||||
message: 'Error confirming subscription',
|
||||
subscribeURL: req.body.SubscribeURL,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -67,6 +98,167 @@ 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;
|
||||
|
||||
// Parse email content if available
|
||||
let htmlBody: string | undefined;
|
||||
|
||||
if (body.content) {
|
||||
try {
|
||||
const parsed = await simpleParser(body.content);
|
||||
// Prefer HTML body, fallback to text if no HTML available
|
||||
htmlBody = parsed.html ? String(parsed.html) : parsed.text || undefined;
|
||||
signale.info('[WEBHOOK] Email content parsed successfully');
|
||||
} catch (parseError) {
|
||||
signale.error('[WEBHOOK] Failed to parse email content:', parseError);
|
||||
// Continue processing without content
|
||||
}
|
||||
}
|
||||
|
||||
// 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 with parsed content
|
||||
const inboundEmail = await prisma.email.create({
|
||||
data: {
|
||||
projectId: domainRecord.projectId,
|
||||
contactId: contact!.id,
|
||||
subject: body.mail?.commonHeaders?.subject || '(No subject)',
|
||||
body: htmlBody || '', // Store HTML body in the body field
|
||||
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 including body content
|
||||
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,
|
||||
// Email body content
|
||||
body: htmlBody,
|
||||
// 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;
|
||||
|
||||
@@ -99,6 +291,7 @@ export class Webhooks {
|
||||
from: email.from,
|
||||
fromName: email.fromName,
|
||||
messageId: email.messageId,
|
||||
emailId: email.id,
|
||||
templateId: email.templateId,
|
||||
campaignId: email.campaignId,
|
||||
sourceType: email.sourceType,
|
||||
@@ -152,29 +345,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 +434,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);
|
||||
}
|
||||
|
||||
@@ -314,6 +542,16 @@ export class Webhooks {
|
||||
const invoice = event.data.object;
|
||||
const customerId = invoice.customer as string;
|
||||
|
||||
// Only disable projects that are already consuming (recurring billing).
|
||||
// If billing_reason is 'subscription_create', this is a first-time payment
|
||||
// attempt and the project has never had an active subscription — don't disable.
|
||||
if (invoice.billing_reason === 'subscription_create') {
|
||||
signale.info(
|
||||
`[WEBHOOK] Payment failed on initial subscription attempt for customer ${customerId}, skipping disable`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Find project by customer ID
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {customer: customerId},
|
||||
@@ -324,10 +562,33 @@ export class Webhooks {
|
||||
break;
|
||||
}
|
||||
|
||||
signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id})`);
|
||||
signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id}), disabling project`);
|
||||
|
||||
// Send notification about payment failure
|
||||
await NtfyService.notifyPaymentFailed(project.name, project.id);
|
||||
await prisma.project.update({
|
||||
where: {id: project.id},
|
||||
data: {disabled: true},
|
||||
});
|
||||
|
||||
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
|
||||
|
||||
// Send email notification to project members
|
||||
try {
|
||||
const members = await MembershipService.getMembers(project.id);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(ProjectDisabledPaymentEmail, {
|
||||
projectName: project.name,
|
||||
projectId: project.id,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Payment Failed', template)),
|
||||
);
|
||||
}
|
||||
} catch (emailError) {
|
||||
signale.error(`[WEBHOOK] Failed to send project disabled email:`, emailError);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>) => {
|
||||
@@ -25,6 +58,8 @@ export function createEmailWorker() {
|
||||
include: {
|
||||
contact: true,
|
||||
project: true,
|
||||
template: {select: {type: true}},
|
||||
campaign: {select: {type: true}},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,7 +73,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: {
|
||||
@@ -72,11 +107,15 @@ export function createEmailWorker() {
|
||||
});
|
||||
|
||||
// Compile HTML with unsubscribe footer and badge
|
||||
// TRANSACTIONAL and HEADLESS emails don't get the Plunk unsubscribe footer
|
||||
const compiledHtml = EmailService.compile({
|
||||
content: formattedEmail.body,
|
||||
contact: email.contact,
|
||||
project: email.project,
|
||||
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
|
||||
includeUnsubscribe:
|
||||
email.sourceType !== EmailSourceType.TRANSACTIONAL &&
|
||||
email.template?.type !== 'HEADLESS' &&
|
||||
email.campaign?.type !== 'HEADLESS',
|
||||
});
|
||||
|
||||
// Use fromName from database if available, otherwise fall back to project name
|
||||
@@ -84,10 +123,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 +158,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,
|
||||
});
|
||||
@@ -133,13 +188,66 @@ export function createEmailWorker() {
|
||||
from: email.from,
|
||||
fromName: email.fromName,
|
||||
messageId: result.messageId,
|
||||
emailId: email.id,
|
||||
templateId: email.templateId,
|
||||
campaignId: email.campaignId,
|
||||
sourceType: email.sourceType,
|
||||
sentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// If this email belongs to a campaign, check if all campaign emails have been sent
|
||||
if (email.campaignId) {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: email.campaignId},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
totalRecipients: true,
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Only check if campaign is still in SENDING status
|
||||
if (campaign && campaign.status === CampaignStatus.SENDING) {
|
||||
// Count how many emails have been sent for this campaign
|
||||
const sentCount = await prisma.email.count({
|
||||
where: {
|
||||
campaignId: email.campaignId,
|
||||
sentAt: {not: null},
|
||||
},
|
||||
});
|
||||
|
||||
// If all emails have been sent, mark campaign as SENT
|
||||
if (sentCount >= campaign.totalRecipients) {
|
||||
await prisma.campaign.update({
|
||||
where: {id: email.campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.SENT,
|
||||
sentCount,
|
||||
},
|
||||
});
|
||||
|
||||
signale.success(
|
||||
`[EMAIL-PROCESSOR] Campaign ${campaign.name} completed: ${sentCount}/${campaign.totalRecipients} emails sent`,
|
||||
);
|
||||
|
||||
// Send notification about campaign send completed
|
||||
const {NtfyService} = await import('../services/NtfyService.js');
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
campaign.name,
|
||||
campaign.project.name,
|
||||
campaign.projectId,
|
||||
campaign.totalRecipients,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
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 +265,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);
|
||||
|
||||
@@ -114,10 +67,14 @@ export class ActivityService {
|
||||
const fetchLimit = effectiveLimit;
|
||||
|
||||
// Default date range to last 30 days if not specified
|
||||
// IMPORTANT: When cursor is provided (pagination), we should NOT apply the gte constraint
|
||||
// to allow users to paginate back beyond the initial date range
|
||||
const now = new Date();
|
||||
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
||||
const dateFilter: Prisma.DateTimeFilter = {
|
||||
gte: startDate || defaultStartDate,
|
||||
// Only apply start date filter on initial load (no cursor)
|
||||
// This allows pagination to go back indefinitely
|
||||
...(cursor ? {} : {gte: startDate || defaultStartDate}),
|
||||
...(endDate ? {lte: endDate} : {}),
|
||||
};
|
||||
|
||||
@@ -156,8 +113,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 +127,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 +135,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 +198,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 +218,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,17 +368,51 @@ export class ActivityService {
|
||||
const where: Prisma.EmailWhereInput = {
|
||||
projectId,
|
||||
...(contactId ? {contactId} : {}),
|
||||
// Apply cursor-based pagination filter on createdAt
|
||||
// This is critical for pagination to work correctly
|
||||
createdAt: cursorTimestamp
|
||||
? {
|
||||
...dateFilter,
|
||||
lt: cursorTimestamp,
|
||||
}
|
||||
: dateFilter,
|
||||
};
|
||||
|
||||
// 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 +439,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 +485,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 +500,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 +531,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 +546,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 +559,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, TemplateType} from '@plunk/db';
|
||||
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
|
||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
||||
|
||||
import {BillingLimitService} from './BillingLimitService.js';
|
||||
import {DomainService} from './DomainService.js';
|
||||
import {EmailService} from './EmailService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {SegmentService} from './SegmentService.js';
|
||||
import {DASHBOARD_URI} 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,
|
||||
@@ -82,10 +59,12 @@ export class CampaignService {
|
||||
from: data.from,
|
||||
fromName: data.fromName,
|
||||
replyTo: data.replyTo,
|
||||
type: data.type ?? TemplateType.MARKETING,
|
||||
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 +73,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,6 +101,10 @@ export class CampaignService {
|
||||
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
|
||||
|
||||
// Handle campaign-specific fields
|
||||
if (data.type !== undefined) {
|
||||
updateData.type = data.type;
|
||||
}
|
||||
|
||||
if (data.audienceType !== undefined) {
|
||||
updateData.audienceType = data.audienceType;
|
||||
}
|
||||
@@ -123,7 +113,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 +137,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 +189,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 +212,7 @@ export class CampaignService {
|
||||
]);
|
||||
|
||||
return {
|
||||
campaigns,
|
||||
data: campaigns,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -252,7 +257,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)`,
|
||||
@@ -262,11 +267,12 @@ export class CampaignService {
|
||||
from: campaign.from,
|
||||
fromName: campaign.fromName,
|
||||
replyTo: campaign.replyTo,
|
||||
type: campaign.type,
|
||||
audienceType: campaign.audienceType,
|
||||
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
||||
segmentId: campaign.segmentId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
totalRecipients: 0,
|
||||
totalRecipients: 0, // Will be updated below
|
||||
sentCount: 0,
|
||||
deliveredCount: 0,
|
||||
openedCount: 0,
|
||||
@@ -274,6 +280,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 +307,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 +443,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 +457,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,
|
||||
@@ -456,23 +488,14 @@ export class CampaignService {
|
||||
from: campaign.from,
|
||||
fromName: campaign.fromName || undefined,
|
||||
replyTo: campaign.replyTo || undefined,
|
||||
isTransactional: campaign.type === TemplateType.TRANSACTIONAL,
|
||||
});
|
||||
} 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 +505,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 +572,7 @@ export class CampaignService {
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {
|
||||
sentCount: sentEmails,
|
||||
deliveredCount: deliveredEmails,
|
||||
openedCount: openedEmails,
|
||||
clickedCount: clickedEmails,
|
||||
@@ -712,7 +715,8 @@ export class CampaignService {
|
||||
): Promise<Prisma.ContactWhereInput> {
|
||||
const baseWhere: Prisma.ContactWhereInput = {
|
||||
projectId,
|
||||
subscribed: true, // Only send to subscribed contacts
|
||||
// Transactional campaigns send to all contacts regardless of subscription status
|
||||
...(campaign.type !== TemplateType.TRANSACTIONAL && {subscribed: true}),
|
||||
};
|
||||
|
||||
switch (campaign.audienceType) {
|
||||
@@ -728,7 +732,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 +768,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,8 @@ interface SendEmailParams {
|
||||
campaignId?: string;
|
||||
workflowExecutionId?: string;
|
||||
workflowStepExecutionId?: string;
|
||||
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
|
||||
isTransactional?: boolean; // Override source type to TRANSACTIONAL (e.g. for transactional campaigns)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,8 +96,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,
|
||||
@@ -112,10 +117,12 @@ export class EmailService {
|
||||
* Send a campaign email
|
||||
*/
|
||||
public static async sendCampaignEmail(params: SendEmailParams): Promise<Email> {
|
||||
// Check if template is transactional to determine source type
|
||||
// Check if campaign or template is transactional to determine source type
|
||||
let sourceType: EmailSourceType = EmailSourceType.CAMPAIGN;
|
||||
|
||||
if (params.templateId) {
|
||||
if (params.isTransactional) {
|
||||
sourceType = EmailSourceType.TRANSACTIONAL;
|
||||
} else if (params.templateId) {
|
||||
const template = await prisma.template.findUnique({
|
||||
where: {id: params.templateId},
|
||||
select: {type: true},
|
||||
@@ -152,8 +159,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 +199,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 +221,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 +249,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 +264,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,
|
||||
@@ -279,9 +293,8 @@ export class EmailService {
|
||||
include: {
|
||||
contact: true,
|
||||
project: true,
|
||||
template: {
|
||||
select: {type: true},
|
||||
},
|
||||
template: {select: {type: true}},
|
||||
campaign: {select: {type: true}},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -332,6 +345,7 @@ export class EmailService {
|
||||
subject: email.subject,
|
||||
body: email.body,
|
||||
data: {
|
||||
id: email.contact.id,
|
||||
email: email.contact.email,
|
||||
...contactData,
|
||||
data: contactData,
|
||||
@@ -342,11 +356,15 @@ export class EmailService {
|
||||
});
|
||||
|
||||
// Compile HTML with unsubscribe footer and badge
|
||||
// TRANSACTIONAL and HEADLESS emails don't get the Plunk unsubscribe footer
|
||||
const compiledHtml = this.compile({
|
||||
content: formattedEmail.body,
|
||||
contact: email.contact,
|
||||
project: email.project,
|
||||
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
|
||||
includeUnsubscribe:
|
||||
email.sourceType !== EmailSourceType.TRANSACTIONAL &&
|
||||
email.template?.type !== 'HEADLESS' &&
|
||||
email.campaign?.type !== 'HEADLESS',
|
||||
});
|
||||
|
||||
// Use explicit fromName if provided, otherwise fall back to project name
|
||||
@@ -359,10 +377,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 +407,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 +440,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 +535,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 +578,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 +599,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 +614,7 @@ export class EmailService {
|
||||
total,
|
||||
sent,
|
||||
delivered,
|
||||
received,
|
||||
opened,
|
||||
clicked,
|
||||
bounced,
|
||||
@@ -585,6 +656,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 +1043,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 +1099,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
|
||||
@@ -204,6 +170,17 @@ export class NtfyService {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about project disabled due to payment failure
|
||||
*/
|
||||
public static async notifyProjectDisabledForPayment(projectName: string, projectId: string): Promise<void> {
|
||||
await this.sendUrgent(
|
||||
'Project Disabled - Payment Failed',
|
||||
`Project "${projectName}" (${projectId}) was automatically disabled due to a failed recurring payment`,
|
||||
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.ERROR],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about successful invoice payment - MIN priority (routine)
|
||||
*/
|
||||
@@ -241,6 +218,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 +240,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 +251,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 +329,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 +600,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 +631,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 +761,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,29 +1,67 @@
|
||||
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
* Security thresholds for bounce and complaint rates
|
||||
* These limits protect AWS SES reputation and prevent account suspension
|
||||
*/
|
||||
const SECURITY_THRESHOLDS = {
|
||||
// Minimum emails required before enforcing limits (prevents false positives)
|
||||
// Minimum emails required before enforcing rate-based limits (prevents false positives)
|
||||
MIN_EMAILS_FOR_ENFORCEMENT: 100,
|
||||
|
||||
// Bounce rate thresholds (hard bounces only)
|
||||
BOUNCE_7DAY_WARNING: 3,
|
||||
BOUNCE_7DAY_CRITICAL: 8,
|
||||
BOUNCE_ALLTIME_WARNING: 2,
|
||||
BOUNCE_ALLTIME_CRITICAL: 5,
|
||||
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 rate-based checks
|
||||
MIN_BOUNCES_FOR_CRITICAL: 10,
|
||||
MIN_BOUNCES_FOR_WARNING: 5,
|
||||
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
||||
MIN_COMPLAINTS_FOR_WARNING: 3,
|
||||
|
||||
// === Absolute count ceilings ===
|
||||
// These trigger regardless of rate — catches high-volume spammers who dilute their bounce rate
|
||||
// 24-hour absolute ceilings
|
||||
BOUNCE_24H_CEILING_WARNING: 50,
|
||||
BOUNCE_24H_CEILING_CRITICAL: 100,
|
||||
COMPLAINT_24H_CEILING_WARNING: 10,
|
||||
COMPLAINT_24H_CEILING_CRITICAL: 25,
|
||||
|
||||
// 7-day absolute ceilings
|
||||
BOUNCE_7DAY_CEILING_WARNING: 200,
|
||||
BOUNCE_7DAY_CEILING_CRITICAL: 500,
|
||||
COMPLAINT_7DAY_CEILING_WARNING: 30,
|
||||
COMPLAINT_7DAY_CEILING_CRITICAL: 75,
|
||||
|
||||
// === New project thresholds (projects < 30 days old) ===
|
||||
// Legitimate senders ramp up gradually; spammers blast immediately
|
||||
NEW_PROJECT_AGE_DAYS: 30,
|
||||
NEW_PROJECT_BOUNCE_24H_CEILING_WARNING: 10,
|
||||
NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL: 25,
|
||||
NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING: 25,
|
||||
NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL: 50,
|
||||
NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING: 3,
|
||||
NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL: 7,
|
||||
NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING: 10,
|
||||
NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL: 20,
|
||||
} as const;
|
||||
|
||||
interface RateData {
|
||||
@@ -38,14 +76,15 @@ interface SecurityStatus {
|
||||
projectId: string;
|
||||
isHealthy: boolean;
|
||||
shouldDisable: boolean;
|
||||
twentyFourHour: RateData;
|
||||
sevenDay: RateData;
|
||||
allTime: RateData;
|
||||
isNewProject: boolean;
|
||||
violations: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class SecurityService {
|
||||
private static readonly CACHE_PREFIX = 'security';
|
||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
@@ -54,7 +93,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) {
|
||||
@@ -75,6 +114,13 @@ export class SecurityService {
|
||||
projectId,
|
||||
isHealthy: true,
|
||||
shouldDisable: false,
|
||||
twentyFourHour: {
|
||||
total: 0,
|
||||
bounces: 0,
|
||||
complaints: 0,
|
||||
bounceRate: 0,
|
||||
complaintRate: 0,
|
||||
},
|
||||
sevenDay: {
|
||||
total: 0,
|
||||
bounces: 0,
|
||||
@@ -89,6 +135,7 @@ export class SecurityService {
|
||||
bounceRate: 0,
|
||||
complaintRate: 0,
|
||||
},
|
||||
isNewProject: false,
|
||||
violations: [],
|
||||
warnings: [],
|
||||
};
|
||||
@@ -107,9 +154,39 @@ 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] 24-hour stats: ${status.twentyFourHour.bounces} bounces, ${status.twentyFourHour.complaints} complaints out of ${status.twentyFourHour.total} emails`,
|
||||
);
|
||||
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`,
|
||||
);
|
||||
if (status.isNewProject) {
|
||||
signale.info(
|
||||
`[SECURITY] Project is under ${SECURITY_THRESHOLDS.NEW_PROJECT_AGE_DAYS} days old — stricter ceilings apply`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,7 +214,7 @@ 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);
|
||||
@@ -145,11 +222,40 @@ export class SecurityService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a project's security metrics (for admin/dashboard display)
|
||||
* 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 dashboard display)
|
||||
* Does NOT expose internal thresholds — only computed health levels
|
||||
*/
|
||||
public static async getProjectSecurityMetrics(projectId: string): Promise<{
|
||||
status: SecurityStatus;
|
||||
thresholds: typeof SECURITY_THRESHOLDS;
|
||||
levels: {
|
||||
bounce7Day: 'healthy' | 'warning' | 'critical';
|
||||
bounceAllTime: 'healthy' | 'warning' | 'critical';
|
||||
complaint7Day: 'healthy' | 'warning' | 'critical';
|
||||
complaintAllTime: 'healthy' | 'warning' | 'critical';
|
||||
};
|
||||
isDisabled: boolean;
|
||||
}> {
|
||||
const [status, project] = await Promise.all([
|
||||
@@ -160,18 +266,53 @@ export class SecurityService {
|
||||
}),
|
||||
]);
|
||||
|
||||
// Strip internal details from the client-facing response:
|
||||
// - Replace detailed violation/warning messages (they contain exact thresholds)
|
||||
// - Remove 24-hour data and new project flag (reveals enforcement windows)
|
||||
const sanitizedStatus: SecurityStatus = {
|
||||
...status,
|
||||
twentyFourHour: {total: 0, bounces: 0, complaints: 0, bounceRate: 0, complaintRate: 0},
|
||||
isNewProject: false,
|
||||
violations: status.violations.map(() => 'Security threshold exceeded'),
|
||||
warnings: status.warnings.map(() => 'Approaching security threshold'),
|
||||
};
|
||||
|
||||
return {
|
||||
status,
|
||||
thresholds: SECURITY_THRESHOLDS,
|
||||
status: sanitizedStatus,
|
||||
levels: {
|
||||
bounce7Day: this.computeLevel(
|
||||
status.sevenDay.bounceRate,
|
||||
SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING,
|
||||
SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL,
|
||||
),
|
||||
bounceAllTime: this.computeLevel(
|
||||
status.allTime.bounceRate,
|
||||
SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING,
|
||||
SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL,
|
||||
),
|
||||
complaint7Day: this.computeLevel(
|
||||
status.sevenDay.complaintRate,
|
||||
SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING,
|
||||
SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL,
|
||||
),
|
||||
complaintAllTime: this.computeLevel(
|
||||
status.allTime.complaintRate,
|
||||
SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING,
|
||||
SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL,
|
||||
),
|
||||
},
|
||||
isDisabled: project?.disabled ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key for security metrics
|
||||
*/
|
||||
private static getCacheKey(projectId: string, type: 'rates'): string {
|
||||
return `${this.CACHE_PREFIX}:${projectId}:${type}`;
|
||||
private static computeLevel(
|
||||
value: number,
|
||||
warningThreshold: number,
|
||||
criticalThreshold: number,
|
||||
): 'healthy' | 'warning' | 'critical' {
|
||||
if (value >= criticalThreshold) return 'critical';
|
||||
if (value >= warningThreshold) return 'warning';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,10 +362,21 @@ export class SecurityService {
|
||||
*/
|
||||
private static async calculateSecurityStatus(projectId: string): Promise<SecurityStatus> {
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Get 7-day and all-time rates in parallel
|
||||
const [sevenDay, allTime] = await Promise.all([
|
||||
// Get project age to determine if stricter new-project thresholds apply
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {createdAt: true},
|
||||
});
|
||||
|
||||
const projectAgeDays = project ? (now.getTime() - project.createdAt.getTime()) / (1000 * 60 * 60 * 24) : Infinity;
|
||||
const isNewProject = projectAgeDays < SECURITY_THRESHOLDS.NEW_PROJECT_AGE_DAYS;
|
||||
|
||||
// Get 24-hour, 7-day and all-time rates in parallel
|
||||
const [twentyFourHour, sevenDay, allTime] = await Promise.all([
|
||||
this.calculateRates(projectId, oneDayAgo),
|
||||
this.calculateRates(projectId, sevenDaysAgo),
|
||||
this.calculateRates(projectId),
|
||||
]);
|
||||
@@ -232,51 +384,158 @@ export class SecurityService {
|
||||
const violations: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Pick absolute count ceilings based on project age
|
||||
const bounceCeilings = isNewProject
|
||||
? {
|
||||
ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING,
|
||||
ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL,
|
||||
ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING,
|
||||
ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL,
|
||||
}
|
||||
: {
|
||||
ceiling24hWarning: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_WARNING,
|
||||
ceiling24hCritical: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_CRITICAL,
|
||||
ceiling7dWarning: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_WARNING,
|
||||
ceiling7dCritical: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_CRITICAL,
|
||||
};
|
||||
|
||||
const complaintCeilings = isNewProject
|
||||
? {
|
||||
ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING,
|
||||
ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL,
|
||||
ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING,
|
||||
ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL,
|
||||
}
|
||||
: {
|
||||
ceiling24hWarning: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_WARNING,
|
||||
ceiling24hCritical: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_CRITICAL,
|
||||
ceiling7dWarning: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_WARNING,
|
||||
ceiling7dCritical: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_CRITICAL,
|
||||
};
|
||||
|
||||
const projectLabel = isNewProject ? ' (new project)' : '';
|
||||
|
||||
// === Absolute count ceiling checks (rate-independent) ===
|
||||
// These catch high-volume spammers who dilute their bounce rate by blasting emails
|
||||
|
||||
// 24-hour bounce ceilings
|
||||
if (twentyFourHour.bounces >= bounceCeilings.ceiling24hCritical) {
|
||||
violations.push(
|
||||
`24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling24hCritical})`,
|
||||
);
|
||||
} else if (twentyFourHour.bounces >= bounceCeilings.ceiling24hWarning) {
|
||||
warnings.push(
|
||||
`24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling24hWarning})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 7-day bounce ceilings
|
||||
if (sevenDay.bounces >= bounceCeilings.ceiling7dCritical) {
|
||||
violations.push(
|
||||
`7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling7dCritical})`,
|
||||
);
|
||||
} else if (sevenDay.bounces >= bounceCeilings.ceiling7dWarning) {
|
||||
warnings.push(
|
||||
`7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling7dWarning})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 24-hour complaint ceilings
|
||||
if (twentyFourHour.complaints >= complaintCeilings.ceiling24hCritical) {
|
||||
violations.push(
|
||||
`24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling24hCritical})`,
|
||||
);
|
||||
} else if (twentyFourHour.complaints >= complaintCeilings.ceiling24hWarning) {
|
||||
warnings.push(
|
||||
`24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling24hWarning})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 7-day complaint ceilings
|
||||
if (sevenDay.complaints >= complaintCeilings.ceiling7dCritical) {
|
||||
violations.push(
|
||||
`7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling7dCritical})`,
|
||||
);
|
||||
} else if (sevenDay.complaints >= complaintCeilings.ceiling7dWarning) {
|
||||
warnings.push(
|
||||
`7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling7dWarning})`,
|
||||
);
|
||||
}
|
||||
|
||||
// === Rate-based checks (existing logic) ===
|
||||
// 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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -285,8 +544,10 @@ export class SecurityService {
|
||||
projectId,
|
||||
isHealthy: violations.length === 0,
|
||||
shouldDisable: violations.length > 0,
|
||||
twentyFourHour,
|
||||
sevenDay,
|
||||
allTime,
|
||||
isNewProject,
|
||||
violations,
|
||||
warnings,
|
||||
};
|
||||
@@ -345,6 +606,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,17 @@
|
||||
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 dns from 'node:dns/promises';
|
||||
import net from 'node:net';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -19,7 +22,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 +82,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 +245,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 +444,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 +521,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 +551,7 @@ export class WorkflowExecutionService {
|
||||
: {};
|
||||
|
||||
const variables = {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
...contactData,
|
||||
...executionContext,
|
||||
@@ -555,10 +564,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 +580,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 +720,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 +736,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 +744,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',
|
||||
};
|
||||
@@ -776,6 +824,89 @@ export class WorkflowExecutionService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that an IP address is not in a private/reserved range to prevent SSRF.
|
||||
* Blocks loopback, private, link-local, and cloud metadata ranges.
|
||||
*/
|
||||
private static isPrivateIp(ip: string): boolean {
|
||||
// Normalize IPv6-mapped IPv4 (e.g. ::ffff:192.168.1.1)
|
||||
const addr = ip.startsWith('::ffff:') ? ip.slice(7) : ip;
|
||||
|
||||
if (net.isIPv4(addr)) {
|
||||
const parts = addr.split('.').map(Number);
|
||||
const a = parts[0] ?? -1;
|
||||
const b = parts[1] ?? -1;
|
||||
|
||||
return (
|
||||
a === 127 || // 127.0.0.0/8 loopback
|
||||
a === 10 || // 10.0.0.0/8 private
|
||||
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
|
||||
(a === 192 && b === 168) || // 192.168.0.0/16 private
|
||||
(a === 169 && b === 254) || // 169.254.0.0/16 link-local / cloud metadata
|
||||
(a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 shared address space
|
||||
a === 0 || // 0.0.0.0/8
|
||||
a >= 224 // 224.0.0.0+ multicast and reserved
|
||||
);
|
||||
}
|
||||
|
||||
if (net.isIPv6(addr)) {
|
||||
const normalized = addr.toLowerCase();
|
||||
return (
|
||||
normalized === '::1' || // loopback
|
||||
normalized.startsWith('fe80:') || // link-local
|
||||
normalized.startsWith('fc') || // unique local
|
||||
normalized.startsWith('fd') || // unique local
|
||||
normalized.startsWith('ff') // multicast
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown format — reject to be safe
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF-safe fetch. Resolves the hostname, validates the IP is not internal,
|
||||
* and manually follows redirects re-validating each hop.
|
||||
*/
|
||||
private static async safeFetch(url: string, options: RequestInit): Promise<Response> {
|
||||
const MAX_REDIRECTS = 5;
|
||||
let currentUrl = url;
|
||||
|
||||
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
||||
const parsed = new URL(currentUrl);
|
||||
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
throw new Error(`Webhook URL scheme not allowed: ${parsed.protocol}`);
|
||||
}
|
||||
|
||||
const {address} = await dns.lookup(parsed.hostname);
|
||||
|
||||
if (WorkflowExecutionService.isPrivateIp(address)) {
|
||||
throw new Error(`Webhook URL resolves to a private/internal IP address: ${address}`);
|
||||
}
|
||||
|
||||
const response = await fetch(currentUrl, {
|
||||
...options,
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) {
|
||||
throw new Error('Redirect with no Location header');
|
||||
}
|
||||
// Resolve relative redirects against the current URL
|
||||
currentUrl = new URL(location, currentUrl).toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
throw new Error('Too many redirects');
|
||||
}
|
||||
|
||||
/**
|
||||
* WEBHOOK step - Call an external webhook
|
||||
*/
|
||||
@@ -813,7 +944,7 @@ export class WorkflowExecutionService {
|
||||
};
|
||||
|
||||
// Make HTTP request
|
||||
const response = await fetch(url, {
|
||||
const response = await WorkflowExecutionService.safeFetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -866,7 +997,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 +1103,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 {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import {beforeEach, describe, expect, it, vi, type Mock} from 'vitest';
|
||||
import {EmailSourceType, EmailStatus, TemplateType} 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(),
|
||||
}));
|
||||
@@ -166,6 +187,84 @@ describe('EmailService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Headless Email Behaviour', () => {
|
||||
it('should NOT send headless workflow emails to unsubscribed contacts', async () => {
|
||||
const unsubscribedContact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
const headlessTemplate = await factories.createTemplate({
|
||||
projectId,
|
||||
type: 'HEADLESS',
|
||||
});
|
||||
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const execution = await factories.createWorkflowExecution(workflow.id, unsubscribedContact.id);
|
||||
|
||||
const email = await EmailService.sendWorkflowEmail({
|
||||
projectId,
|
||||
contactId: unsubscribedContact.id,
|
||||
templateId: headlessTemplate.id,
|
||||
subject: 'Newsletter',
|
||||
body: 'Content',
|
||||
from: 'test@example.com',
|
||||
workflowExecutionId: execution.id,
|
||||
});
|
||||
|
||||
expect(email.status).toBe(EmailStatus.FAILED);
|
||||
expect(email.error).toMatch(/unsubscribed/i);
|
||||
});
|
||||
|
||||
it('should keep CAMPAIGN sourceType when campaign type is HEADLESS (no template)', async () => {
|
||||
const contact = await factories.createContact({projectId, subscribed: true});
|
||||
|
||||
// Campaign typed HEADLESS directly — no template involved (inline body)
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
type: TemplateType.HEADLESS,
|
||||
body: 'Content with <a href="https://example.com/unsubscribe">unsubscribe</a>',
|
||||
});
|
||||
|
||||
const email = await EmailService.sendCampaignEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
campaignId: campaign.id,
|
||||
subject: 'Newsletter',
|
||||
body: campaign.body,
|
||||
from: 'news@example.com',
|
||||
});
|
||||
|
||||
expect(email.sourceType).toBe(EmailSourceType.CAMPAIGN);
|
||||
expect(email.status).toBe(EmailStatus.PENDING);
|
||||
});
|
||||
|
||||
it('should keep CAMPAIGN sourceType when campaign uses headless template', async () => {
|
||||
const contact = await factories.createContact({projectId, subscribed: true});
|
||||
|
||||
const headlessTemplate = await factories.createTemplate({
|
||||
projectId,
|
||||
type: 'HEADLESS',
|
||||
});
|
||||
|
||||
const campaign = await factories.createCampaign({projectId});
|
||||
|
||||
const email = await EmailService.sendCampaignEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
campaignId: campaign.id,
|
||||
templateId: headlessTemplate.id,
|
||||
subject: 'Newsletter',
|
||||
body: 'Content with <a href="https://example.com/unsubscribe">unsubscribe</a>',
|
||||
from: 'news@example.com',
|
||||
});
|
||||
|
||||
// HEADLESS is not transactional — sourceType stays CAMPAIGN
|
||||
expect(email.sourceType).toBe(EmailSourceType.CAMPAIGN);
|
||||
expect(email.status).toBe(EmailStatus.PENDING);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Template Type Determines Email Type', () => {
|
||||
it('should use TRANSACTIONAL sourceType when campaign uses transactional template', async () => {
|
||||
const contact = await factories.createContact({
|
||||
@@ -669,7 +768,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 +788,6 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should validate attachment size limit (10MB total)', () => {
|
||||
|
||||
// Exceeds ~13.3M base64 chars limit
|
||||
const largeContent = 'A'.repeat(14000000);
|
||||
|
||||
@@ -711,11 +808,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 +828,6 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should reject attachment with missing required fields', () => {
|
||||
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
@@ -748,7 +844,6 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should reject attachment with empty filename', () => {
|
||||
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
@@ -766,7 +861,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 +880,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 +900,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {EmailStatus, EmailSourceType} from '@plunk/db';
|
||||
import {SecurityService} from '../SecurityService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {redis} from '../../database/redis';
|
||||
|
||||
vi.mock('../../app/constants.js', async () => {
|
||||
const actual = await vi.importActual('../../app/constants.js');
|
||||
return {
|
||||
...actual,
|
||||
AUTO_PROJECT_DISABLE: true,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock NtfyService to prevent actual notifications
|
||||
vi.mock('../NtfyService.js', () => ({
|
||||
NtfyService: {
|
||||
notifySecurityWarning: vi.fn(),
|
||||
notifyProjectDisabledForSecurity: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock email sending for project disabled notifications
|
||||
vi.mock('@plunk/email', () => ({
|
||||
ProjectDisabledEmail: vi.fn(),
|
||||
sendPlatformEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('SecurityService', () => {
|
||||
let projectId: string;
|
||||
let contactId: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
|
||||
const contact = await factories.createContact({projectId});
|
||||
contactId = contact.id;
|
||||
|
||||
// Clear redis cache
|
||||
await redis.flushdb();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create N emails, some of which are bounced
|
||||
*/
|
||||
async function createEmails(count: number, opts?: {bouncedCount?: number; complainedCount?: number; createdAt?: Date}) {
|
||||
const bouncedCount = opts?.bouncedCount ?? 0;
|
||||
const complainedCount = opts?.complainedCount ?? 0;
|
||||
const createdAt = opts?.createdAt ?? new Date();
|
||||
|
||||
const emails = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
emails.push(
|
||||
prisma.email.create({
|
||||
data: {
|
||||
projectId,
|
||||
contactId,
|
||||
subject: `Test ${i}`,
|
||||
body: '<p>test</p>',
|
||||
from: 'test@example.com',
|
||||
status: EmailStatus.SENT,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
sentAt: createdAt,
|
||||
createdAt,
|
||||
bouncedAt: i < bouncedCount ? createdAt : null,
|
||||
complainedAt: i >= bouncedCount && i < bouncedCount + complainedCount ? createdAt : null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(emails);
|
||||
}
|
||||
|
||||
describe('Rate-based checks (existing behavior)', () => {
|
||||
it('should report healthy when bounce rate is below warning', async () => {
|
||||
// 200 emails, 5 bounces = 2.5% (below 5% warning)
|
||||
await createEmails(200, {bouncedCount: 5});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isHealthy).toBe(true);
|
||||
expect(status.shouldDisable).toBe(false);
|
||||
expect(status.violations).toHaveLength(0);
|
||||
expect(status.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should trigger warning when 7-day bounce rate exceeds warning threshold', async () => {
|
||||
// 100 emails, 6 bounces = 6% (above 5% warning, below 10% critical)
|
||||
await createEmails(100, {bouncedCount: 6});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isHealthy).toBe(true);
|
||||
expect(status.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should trigger violation when 7-day bounce rate exceeds critical threshold', async () => {
|
||||
// 100 emails, 11 bounces = 11% (above 10% critical)
|
||||
await createEmails(100, {bouncedCount: 11});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isHealthy).toBe(false);
|
||||
expect(status.shouldDisable).toBe(true);
|
||||
expect(status.violations.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not enforce rate checks when below minimum volume', async () => {
|
||||
// 50 emails (below 100 minimum), 10 bounces = 20% (would exceed critical)
|
||||
await createEmails(50, {bouncedCount: 10});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
// Rate-based check doesn't trigger, but absolute count ceiling might
|
||||
// With 10 bounces in 24h, this is below the 50-bounce ceiling for established projects
|
||||
expect(status.violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Absolute count ceilings (established projects)', () => {
|
||||
// Age the project past the new-project window so standard ceilings apply
|
||||
beforeEach(async () => {
|
||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {createdAt: oldDate},
|
||||
});
|
||||
});
|
||||
|
||||
it('should trigger critical when 24-hour bounce count exceeds ceiling', async () => {
|
||||
// 20,000 emails, 101 bounces = 0.5% rate (well below rate threshold)
|
||||
// But 101 bounces > 100 (24h critical ceiling for established projects)
|
||||
await createEmails(20000, {bouncedCount: 101});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.shouldDisable).toBe(true);
|
||||
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger warning when 24-hour bounce count exceeds warning ceiling', async () => {
|
||||
// 10,000 emails, 51 bounces = 0.51% (below rate threshold)
|
||||
// But 51 > 50 (24h warning ceiling), below 100 critical
|
||||
await createEmails(10000, {bouncedCount: 51});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isHealthy).toBe(true); // warnings don't make it unhealthy
|
||||
expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger critical when 24-hour complaint count exceeds ceiling', async () => {
|
||||
// 20,000 emails, 26 complaints = 0.13% (below complaint rate critical of 0.15%)
|
||||
// But 26 > 25 (24h complaint critical ceiling)
|
||||
await createEmails(20000, {complainedCount: 26});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.shouldDisable).toBe(true);
|
||||
expect(status.violations.some(v => v.includes('24-hour complaint count'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT trigger ceiling when bounce count is below ceiling', async () => {
|
||||
// 20,000 emails, 40 bounces = below 50 warning ceiling for established projects
|
||||
await createEmails(20000, {bouncedCount: 40});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isHealthy).toBe(true);
|
||||
expect(status.violations).toHaveLength(0);
|
||||
expect(status.warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('New project stricter thresholds', () => {
|
||||
it('should apply stricter ceilings for projects under 30 days old', async () => {
|
||||
// Default project is created "now", so it's a new project
|
||||
// 10,000 emails, 26 bounces (above 25 new project 24h critical ceiling)
|
||||
await createEmails(10000, {bouncedCount: 26});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isNewProject).toBe(true);
|
||||
expect(status.shouldDisable).toBe(true);
|
||||
expect(status.violations.some(v => v.includes('new project'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply standard ceilings for projects over 30 days old', async () => {
|
||||
// Age the project to 31 days
|
||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {createdAt: oldDate},
|
||||
});
|
||||
|
||||
// 10,000 emails, 26 bounces (above 25 new project ceiling, below 50 standard warning ceiling)
|
||||
await createEmails(10000, {bouncedCount: 26});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isNewProject).toBe(false);
|
||||
// 26 is below the 50-bounce 24h warning ceiling for established projects
|
||||
expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(false);
|
||||
// And below the 100-bounce 24h critical ceiling
|
||||
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should catch new project blasting emails with delayed bounces', async () => {
|
||||
// Simulate the spammer scenario: new project sends 20K emails,
|
||||
// only 30 bounces have come back so far (rate is tiny: 0.15%)
|
||||
await createEmails(20000, {bouncedCount: 30});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isNewProject).toBe(true);
|
||||
expect(status.shouldDisable).toBe(true);
|
||||
// 30 > 25 new project 24h critical ceiling
|
||||
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAndEnforceSecurityLimits', () => {
|
||||
it('should disable project when critical thresholds are exceeded', async () => {
|
||||
// Create enough bounces to trigger critical
|
||||
await createEmails(20000, {bouncedCount: 101});
|
||||
|
||||
await SecurityService.checkAndEnforceSecurityLimits(projectId);
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {disabled: true},
|
||||
});
|
||||
expect(project?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT disable project when only warnings exist', async () => {
|
||||
// 10,000 emails, 51 bounces (above warning but below critical for established project)
|
||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {createdAt: oldDate},
|
||||
});
|
||||
await createEmails(10000, {bouncedCount: 51});
|
||||
|
||||
await SecurityService.checkAndEnforceSecurityLimits(projectId);
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {disabled: true},
|
||||
});
|
||||
expect(project?.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectSecurityMetrics (client-facing)', () => {
|
||||
it('should NOT expose internal thresholds or detailed violation messages', async () => {
|
||||
// Create a violation scenario
|
||||
await createEmails(100, {bouncedCount: 15});
|
||||
|
||||
const metrics = await SecurityService.getProjectSecurityMetrics(projectId);
|
||||
|
||||
// Should have levels, not thresholds
|
||||
expect(metrics.levels).toBeDefined();
|
||||
expect((metrics as Record<string, unknown>).thresholds).toBeUndefined();
|
||||
|
||||
// Violation messages should be generic
|
||||
if (metrics.status.violations.length > 0) {
|
||||
for (const v of metrics.status.violations) {
|
||||
expect(v).toBe('Security threshold exceeded');
|
||||
expect(v).not.toMatch(/\d+%/); // No percentages
|
||||
expect(v).not.toMatch(/\d+ minimum/); // No absolute numbers
|
||||
}
|
||||
}
|
||||
|
||||
// 24-hour data should be zeroed out
|
||||
expect(metrics.status.twentyFourHour.total).toBe(0);
|
||||
expect(metrics.status.twentyFourHour.bounces).toBe(0);
|
||||
|
||||
// New project flag should be hidden
|
||||
expect(metrics.status.isNewProject).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')]),
|
||||
);
|
||||
});
|
||||
@@ -193,14 +193,19 @@ describe('TemplateService', () => {
|
||||
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
|
||||
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
|
||||
await factories.createTemplate({projectId, type: TemplateType.TRANSACTIONAL});
|
||||
await factories.createTemplate({projectId, type: TemplateType.HEADLESS});
|
||||
|
||||
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);
|
||||
|
||||
const headlessResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.HEADLESS);
|
||||
expect(headlessResult.total).toBe(1);
|
||||
expect(headlessResult.data[0].type).toBe(TemplateType.HEADLESS);
|
||||
});
|
||||
|
||||
it('should combine search and type filters', async () => {
|
||||
@@ -223,7 +228,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 +241,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 () => {
|
||||
@@ -293,6 +298,19 @@ describe('TemplateService', () => {
|
||||
expect(updated.type).toBe(TemplateType.TRANSACTIONAL);
|
||||
});
|
||||
|
||||
it('should update template type to HEADLESS', async () => {
|
||||
const template = await factories.createTemplate({
|
||||
projectId,
|
||||
type: TemplateType.MARKETING,
|
||||
});
|
||||
|
||||
const updated = await TemplateService.update(projectId, template.id, {
|
||||
type: TemplateType.HEADLESS,
|
||||
});
|
||||
|
||||
expect(updated.type).toBe(TemplateType.HEADLESS);
|
||||
});
|
||||
|
||||
it('should update email fields (from, fromName, replyTo)', async () => {
|
||||
const template = await factories.createTemplate({projectId});
|
||||
|
||||
|
||||
@@ -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,8 +1,15 @@
|
||||
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';
|
||||
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
default: {
|
||||
lookup: vi.fn(async () => ({address: '1.2.3.4', family: 4})),
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Integration Tests: Workflow Execution Engine
|
||||
*
|
||||
@@ -59,11 +66,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 +81,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 +91,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 +104,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: yesStep.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
@@ -106,7 +113,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: noStep.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
priority: 2,
|
||||
},
|
||||
});
|
||||
@@ -118,7 +125,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -160,11 +167,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 +181,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Premium',
|
||||
position: {x: 200, y: -50},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -184,7 +191,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Standard',
|
||||
position: {x: 200, y: 50},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -196,7 +203,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: yesStep.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -204,7 +211,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: noStep.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -214,7 +221,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -250,7 +257,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 +268,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 +278,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'US Premium',
|
||||
position: {x: 300, y: -75},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -281,7 +288,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'US Standard',
|
||||
position: {x: 300, y: -25},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -291,7 +298,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Non-US',
|
||||
position: {x: 200, y: 50},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -304,7 +311,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition1.id,
|
||||
toStepId: condition2.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -312,7 +319,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition1.id,
|
||||
toStepId: nonUsExit.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -320,7 +327,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition2.id,
|
||||
toStepId: usPremiumExit.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -328,7 +335,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition2.id,
|
||||
toStepId: usStandardExit.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -338,7 +345,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -378,10 +385,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 +398,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Complete',
|
||||
position: {x: 200, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -409,7 +416,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -447,10 +454,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 +467,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Done',
|
||||
position: {x: 200, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -478,7 +485,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -526,7 +533,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 +543,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 +553,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Complete',
|
||||
position: {x: 300, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -561,7 +568,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition.id,
|
||||
toStepId: exit.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -571,7 +578,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -611,7 +618,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 +628,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 +638,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 +648,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Merge Point',
|
||||
position: {x: 300, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -653,7 +660,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition.id,
|
||||
toStepId: pathA.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -661,7 +668,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition.id,
|
||||
toStepId: pathB.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -679,7 +686,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -717,7 +724,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 +738,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -764,11 +771,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 +785,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Exit',
|
||||
position: {x: 200, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -790,7 +797,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition.id,
|
||||
toStepId: noStep.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -800,7 +807,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -834,7 +841,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 +855,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -882,7 +889,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 +897,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'End',
|
||||
position: {x: 100, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
});
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
@@ -910,7 +917,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: contextData as Prisma.InputJsonValue,
|
||||
context: toPrismaJson(contextData),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -974,11 +981,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 +995,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 +1005,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 +1014,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 +1027,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 +1063,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 +1077,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 +1088,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: exitStep.id,
|
||||
condition: {branch: 'yes'} as Prisma.InputJsonValue,
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1091,10 +1098,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 +1129,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 +1143,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 +1154,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: exitStep.id,
|
||||
condition: {branch: 'yes'} as Prisma.InputJsonValue,
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1157,10 +1164,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 +1211,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 +1228,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": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 24 KiB |
@@ -1,7 +1,3 @@
|
||||
<svg viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1080" height="1080" fill="white"/>
|
||||
<path d="M976 284.628C976 348.237 959.54 406.608 926.62 459.74C893.701 512.873 845.817 556.276 782.97 589.952C720.124 623.627 645.306 643.458 558.517 649.445L510.26 919.971C491.556 1023.99 440.68 1076 357.632 1076C311.993 1076 269.721 1062.53 230.816 1035.59C192.659 1008.65 161.984 967.49 138.79 912.113C115.597 856.736 104 788.637 104 707.816C104 555.902 128.316 427.187 176.947 321.671C226.327 215.407 292.166 136.082 374.466 83.6984C457.514 30.5661 548.791 4 648.299 4C718.627 4 778.107 16.3476 826.739 41.0429C876.118 65.7382 913.153 99.4136 937.843 142.069C963.281 183.976 976 231.496 976 284.628ZM578.718 533.826C732.094 514.369 808.783 434.671 808.783 294.731C808.783 245.34 792.323 205.304 759.403 174.622C727.231 143.192 677.103 127.476 609.019 127.476C531.957 127.476 464.621 151.798 407.012 200.44C350.15 249.082 306.008 316.807 274.584 403.615C243.909 489.674 228.571 588.081 228.571 698.836C228.571 745.233 233.06 786.392 242.039 822.312C251.765 858.232 263.736 886.295 277.951 906.501C292.915 925.957 307.13 935.686 320.597 935.686C339.302 935.686 353.517 909.868 363.243 858.232L400.278 646.078C371.099 641.587 358.38 639.717 362.121 640.465C339.676 636.723 325.086 629.988 318.353 620.26C311.619 609.783 308.252 596.687 308.252 580.972C308.252 564.508 312.741 551.412 321.719 541.684C331.446 531.955 344.539 527.091 360.999 527.091C368.481 527.091 374.092 527.465 377.833 528.214C395.789 531.207 409.63 533.078 419.357 533.826C429.083 475.455 442.924 397.254 460.88 299.221C465.369 273.777 475.47 255.817 491.182 245.34C507.641 234.115 526.72 228.503 548.417 228.503C573.107 228.503 590.689 233.367 601.163 243.095C612.386 252.075 617.997 266.668 617.997 286.873C617.997 298.847 617.249 308.575 615.753 316.059L578.718 533.826Z"
|
||||
fill="black"/>
|
||||
<path d="M304.835 467.541L426.099 489.952L411.851 580.937L391.091 699.816L266.088 676.643L304.835 467.541Z"
|
||||
fill="white"/>
|
||||
<svg width="1080" height="1080" viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z" fill="black"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -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,75 @@
|
||||
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[];
|
||||
}
|
||||
|
||||
export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
|
||||
return (
|
||||
<div className={'overflow-hidden rounded-[24px] border border-neutral-200'}>
|
||||
<div className={'grid grid-cols-3 gap-px bg-neutral-200'}>
|
||||
<div className={'bg-neutral-50 p-6'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Feature</span>
|
||||
</div>
|
||||
<div className={'bg-neutral-900 p-6 text-center'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-white'}>Plunk</span>
|
||||
</div>
|
||||
<div className={'bg-neutral-50 p-6 text-center'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>{competitorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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.4, delay: index * 0.04, 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-700'}>{row.feature}</span>
|
||||
</div>
|
||||
<div className={'bg-neutral-50/70 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-300" strokeWidth={2} />
|
||||
)
|
||||
) : (
|
||||
<span className={'text-sm font-medium 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-300" strokeWidth={2} />
|
||||
)
|
||||
) : (
|
||||
<span className={'text-sm text-neutral-600'}>{row.competitor}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<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={'mb-12'}
|
||||
>
|
||||
<h2
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className={'mx-auto max-w-3xl divide-y divide-neutral-200'}>
|
||||
{faqs.map((faq, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'py-8'}
|
||||
>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-lg font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{faq.question}
|
||||
</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>{faq.answer}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,15 +10,15 @@ export default function Footer() {
|
||||
return (
|
||||
<>
|
||||
<footer className={'border-t border-neutral-200 bg-white'}>
|
||||
<div className="mx-auto max-w-7xl px-8 py-20 xl:px-0">
|
||||
<div className="mx-auto max-w-[88rem] px-6 py-20 sm:px-10">
|
||||
<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,9 +57,9 @@ 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>
|
||||
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Product</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link href={'/pricing'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
@@ -67,24 +67,102 @@ export default function Footer() {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/made-by-humans'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Made by humans
|
||||
<Link href={WIKI_URI} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Documentation
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
||||
>
|
||||
Documentation
|
||||
<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">Community</h3>
|
||||
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Features</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<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 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Community</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link href={'/discord'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
@@ -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 style={{fontFamily: 'var(--font-mono)'}} className="mt-8 text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">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 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">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-200 bg-white/95 backdrop-blur-sm'}>
|
||||
<div className={'relative mx-auto max-w-[88rem] px-6 sm:px-10'}>
|
||||
<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-[16px] 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-[10px] 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-full bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white 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 style={{fontFamily: 'var(--font-mono)'}} className="px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] 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-full 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>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user