Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -112,6 +112,25 @@ SMTP_DOMAIN=smtp.example.com
|
|||||||
# Maximum recipients per email (default: 5)
|
# Maximum recipients per email (default: 5)
|
||||||
# MAX_RECIPIENTS=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
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# ADVANCED (rarely needed)
|
# ADVANCED (rarely needed)
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
IMAGE="ghcr.io/${{ github.repository }}"
|
IMAGE="ghcr.io/${{ github.repository }}"
|
||||||
|
|
||||||
if [[ "${{ steps.check-release.outputs.is_release }}" == "true" ]]; then
|
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 }}"
|
TAG="${{ steps.check-release.outputs.release_tag }}"
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||||
@@ -70,9 +70,9 @@ jobs:
|
|||||||
TAGS="${VERSION},${MAJOR}.${MINOR},${MAJOR},latest"
|
TAGS="${VERSION},${MAJOR}.${MINOR},${MAJOR},latest"
|
||||||
echo "Building RELEASE with tags: $TAGS"
|
echo "Building RELEASE with tags: $TAGS"
|
||||||
else
|
else
|
||||||
# Regular commit: use SHA tags
|
# Regular commit: use SHA tags only (no latest)
|
||||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||||
TAGS="sha-${SHORT_SHA},latest"
|
TAGS="sha-${SHORT_SHA}"
|
||||||
echo "Building COMMIT with tags: $TAGS"
|
echo "Building COMMIT with tags: $TAGS"
|
||||||
fi
|
fi
|
||||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.1.0"
|
".": "0.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,60 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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)
|
## [0.1.0](https://github.com/useplunk/plunk/compare/v0.0.1...v0.1.0) (2025-12-08)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ 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 (optional): `STRIPE_SK`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ONBOARDING`, `STRIPE_PRICE_EMAIL_USAGE`,
|
||||||
`STRIPE_METER_EVENT_NAME`
|
`STRIPE_METER_EVENT_NAME`
|
||||||
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
- 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)
|
||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ The easiest way to self-host Plunk is by using the `plunk` Docker image.
|
|||||||
You can pull the latest image from [Github](https://github.com/useplunk/plunk/pkgs/container/plunk).
|
You can pull the latest image from [Github](https://github.com/useplunk/plunk/pkgs/container/plunk).
|
||||||
|
|
||||||
A complete guide on how to deploy Plunk can be found in
|
A complete guide on how to deploy Plunk can be found in
|
||||||
the [documentation](https://docs.useplunk.com/self-hosting/introduction).
|
the [documentation](https://next-wiki.useplunk.com/self-hosting/introduction).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ AWS_SES_SECRET_ACCESS_KEY=
|
|||||||
SES_CONFIGURATION_SET=plunk-configuration-set # Default: with open/click tracking
|
SES_CONFIGURATION_SET=plunk-configuration-set # Default: with open/click tracking
|
||||||
SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optional: without tracking (enables toggle in UI)
|
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)
|
# OAuth (Optional - for social login)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
@@ -70,3 +76,11 @@ STRIPE_WEBHOOK_SECRET=
|
|||||||
STRIPE_PRICE_ONBOARDING= # Optional: One-time onboarding fee price ID (e.g., price_xxxxx)
|
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_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)
|
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
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
it('should validate subject and body required when no template', () => {
|
it('should validate subject and body required when no template', () => {
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: '[email protected]',
|
to: '[email protected]',
|
||||||
|
from: '[email protected]',
|
||||||
// Missing subject, body, and template
|
// Missing subject, body, and template
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,6 +170,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
it('should accept to as string (backward compatible)', () => {
|
it('should accept to as string (backward compatible)', () => {
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: '[email protected]',
|
to: '[email protected]',
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
});
|
});
|
||||||
@@ -182,6 +184,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
name: 'Jane Doe',
|
name: 'Jane Doe',
|
||||||
email: '[email protected]',
|
email: '[email protected]',
|
||||||
},
|
},
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
});
|
});
|
||||||
@@ -194,6 +197,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
to: {
|
to: {
|
||||||
email: '[email protected]',
|
email: '[email protected]',
|
||||||
},
|
},
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
});
|
});
|
||||||
@@ -204,6 +208,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
it('should accept to as array of strings', () => {
|
it('should accept to as array of strings', () => {
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: ['[email protected]', '[email protected]'],
|
to: ['[email protected]', '[email protected]'],
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
});
|
});
|
||||||
@@ -217,6 +222,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
{name: 'Jane Doe', email: '[email protected]'},
|
{name: 'Jane Doe', email: '[email protected]'},
|
||||||
{name: 'John Smith', email: '[email protected]'},
|
{name: 'John Smith', email: '[email protected]'},
|
||||||
],
|
],
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
});
|
});
|
||||||
@@ -227,6 +233,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
it('should accept to as mixed array of strings and objects', () => {
|
it('should accept to as mixed array of strings and objects', () => {
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: ['[email protected]', {name: 'John Smith', email: '[email protected]'}],
|
to: ['[email protected]', {name: 'John Smith', email: '[email protected]'}],
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
});
|
});
|
||||||
@@ -442,4 +449,288 @@ 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: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result2 = ActionSchemas.track.safeParse({
|
||||||
|
event: 'contact.updated',
|
||||||
|
email: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result2 = ActionSchemas.track.safeParse({
|
||||||
|
event: 'segment.premium.updated',
|
||||||
|
email: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-2
@@ -14,12 +14,13 @@ import {
|
|||||||
GOOGLE_OAUTH_ENABLED,
|
GOOGLE_OAUTH_ENABLED,
|
||||||
LANDING_URI,
|
LANDING_URI,
|
||||||
NODE_ENV,
|
NODE_ENV,
|
||||||
|
PLUNK_ENABLED,
|
||||||
PORT,
|
PORT,
|
||||||
S3_ENABLED,
|
S3_ENABLED,
|
||||||
SMTP_ENABLED,
|
SMTP_ENABLED,
|
||||||
STRIPE_ENABLED,
|
STRIPE_ENABLED,
|
||||||
TRACKING_TOGGLE_ENABLED,
|
TRACKING_TOGGLE_ENABLED,
|
||||||
WIKI_URI,
|
WIKI_URI
|
||||||
} from './app/constants.js';
|
} from './app/constants.js';
|
||||||
import {Actions} from './controllers/Actions.js';
|
import {Actions} from './controllers/Actions.js';
|
||||||
import {Activity} from './controllers/Activity.js';
|
import {Activity} from './controllers/Activity.js';
|
||||||
@@ -309,7 +310,7 @@ server.app.use((error: Error, req: Request, res: Response, _next: NextFunction)
|
|||||||
// Global error handlers to prevent server crashes
|
// Global error handlers to prevent server crashes
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
signale.error('Unhandled Promise Rejection:', reason);
|
signale.error('Unhandled Promise Rejection:', reason);
|
||||||
console.error('Promise:', promise);
|
signale.error('Promise:', promise);
|
||||||
// Don't exit the process - just log the error
|
// Don't exit the process - just log the error
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -351,6 +352,11 @@ void prisma.$connect().then(async () => {
|
|||||||
? 'Per-project tracking toggle enabled'
|
? 'Per-project tracking toggle enabled'
|
||||||
: 'Always tracking or always no-tracking',
|
: '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 => ({
|
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_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
|
||||||
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
|
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
|
// Storage
|
||||||
export const REDIS_URL = validateEnv('REDIS_URL');
|
export const REDIS_URL = validateEnv('REDIS_URL');
|
||||||
export const DATABASE_URL = validateEnv('DATABASE_URL');
|
export const DATABASE_URL = validateEnv('DATABASE_URL');
|
||||||
@@ -87,3 +94,12 @@ 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
|
// Enable SMTP features only when explicitly enabled via env or when a non-default domain is configured
|
||||||
export const SMTP_ENABLED =
|
export const SMTP_ENABLED =
|
||||||
process.env.SMTP_ENABLED === 'true' || (SMTP_DOMAIN !== 'localhost' && NODE_ENV !== 'development');
|
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';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {ContactService} from '../services/ContactService.js';
|
|||||||
import {DomainService} from '../services/DomainService.js';
|
import {DomainService} from '../services/DomainService.js';
|
||||||
import {EmailService} from '../services/EmailService.js';
|
import {EmailService} from '../services/EmailService.js';
|
||||||
import {EventService} from '../services/EventService.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 {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +55,21 @@ export class Actions {
|
|||||||
// Zod validation - errors automatically handled by global error handler
|
// Zod validation - errors automatically handled by global error handler
|
||||||
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
|
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
|
// Create or update contact with persistent data only
|
||||||
// ContactService.upsert will filter out non-persistent fields
|
// ContactService.upsert will filter out non-persistent fields
|
||||||
const contact = await ContactService.upsert(
|
const contact = await ContactService.upsert(
|
||||||
@@ -223,14 +238,21 @@ export class Actions {
|
|||||||
templateId = templateRecord.id;
|
templateId = templateRecord.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify 'from' domain is verified if provided
|
if (!emailFrom) {
|
||||||
const senderEmail = emailFrom || '[email protected]'; // Default sender
|
throw new ValidationError(
|
||||||
|
[
|
||||||
// Only verify custom domains (not the default [email protected])
|
{
|
||||||
if (emailFrom && emailFrom !== '[email protected]') {
|
field: 'from',
|
||||||
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
|
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 replyToEmail = emailReplyTo;
|
||||||
|
|
||||||
const timestamp = new Date();
|
const timestamp = new Date();
|
||||||
@@ -281,7 +303,7 @@ export class Actions {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
subject: renderedSubject,
|
subject: renderedSubject,
|
||||||
body: renderedBody,
|
body: renderedBody,
|
||||||
from: senderEmail,
|
from: emailFrom,
|
||||||
fromName: emailFromName,
|
fromName: emailFromName,
|
||||||
toName: recipient.name,
|
toName: recipient.name,
|
||||||
replyTo: replyToEmail,
|
replyTo: replyToEmail,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
||||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||||
import {CampaignSchemas} from '@plunk/shared';
|
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
@@ -97,7 +97,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async get(req: Request, res: Response, _next: NextFunction) {
|
private async get(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const campaign = await CampaignService.get(auth.projectId, id!);
|
const campaign = await CampaignService.get(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async update(req: Request, res: Response, _next: NextFunction) {
|
private async update(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
||||||
req.body;
|
req.body;
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async delete(req: Request, res: Response, _next: NextFunction) {
|
private async delete(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
await CampaignService.delete(auth.projectId, id!);
|
await CampaignService.delete(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const campaign = await CampaignService.duplicate(auth.projectId, id!);
|
const campaign = await CampaignService.duplicate(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async send(req: Request, res: Response, _next: NextFunction) {
|
private async send(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const scheduledFor = req.body?.scheduledFor;
|
const scheduledFor = req.body?.scheduledFor;
|
||||||
|
|
||||||
// Parse scheduledFor if provided
|
// Parse scheduledFor if provided
|
||||||
@@ -232,7 +232,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const campaign = await CampaignService.cancel(auth.projectId, id!);
|
const campaign = await CampaignService.cancel(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async stats(req: Request, res: Response, _next: NextFunction) {
|
private async stats(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const stats = await CampaignService.getStats(auth.projectId, id!);
|
const stats = await CampaignService.getStats(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const {email} = CampaignSchemas.sendTest.parse(req.body);
|
const {email} = CampaignSchemas.sendTest.parse(req.body);
|
||||||
|
|
||||||
await CampaignService.sendTest(auth.projectId, id!, email);
|
await CampaignService.sendTest(auth.projectId, id!, email);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {requireAuth} from '../middleware/auth.js';
|
import {requireAuth} from '../middleware/auth.js';
|
||||||
@@ -63,7 +64,7 @@ export class Contacts {
|
|||||||
count: fieldsWithTypes.length,
|
count: fieldsWithTypes.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
||||||
});
|
});
|
||||||
@@ -97,7 +98,7 @@ export class Contacts {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to get field values',
|
error: error instanceof Error ? error.message : 'Failed to get field values',
|
||||||
});
|
});
|
||||||
@@ -288,7 +289,7 @@ export class Contacts {
|
|||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CONTACTS] Failed to queue import:', error);
|
signale.error('[CONTACTS] Failed to queue import:', error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to queue import',
|
error: error instanceof Error ? error.message : 'Failed to queue import',
|
||||||
});
|
});
|
||||||
@@ -318,7 +319,7 @@ export class Contacts {
|
|||||||
|
|
||||||
return res.status(200).json(status);
|
return res.status(200).json(status);
|
||||||
} catch (error) {
|
} 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({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to get import status',
|
error: error instanceof Error ? error.message : 'Failed to get import status',
|
||||||
});
|
});
|
||||||
@@ -345,7 +346,7 @@ export class Contacts {
|
|||||||
const usage = await ContactService.getFieldUsage(auth.projectId!, field);
|
const usage = await ContactService.getFieldUsage(auth.projectId!, field);
|
||||||
return res.status(200).json(usage);
|
return res.status(200).json(usage);
|
||||||
} catch (error) {
|
} 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({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to get field usage',
|
error: error instanceof Error ? error.message : 'Failed to get field usage',
|
||||||
});
|
});
|
||||||
@@ -372,7 +373,7 @@ export class Contacts {
|
|||||||
const result = await ContactService.deleteField(auth.projectId!, field);
|
const result = await ContactService.deleteField(auth.projectId!, field);
|
||||||
return res.status(200).json(result);
|
return res.status(200).json(result);
|
||||||
} catch (error) {
|
} 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({
|
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to delete field',
|
error: error instanceof Error ? error.message : 'Failed to delete field',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {requireAuth} from '../middleware/auth.js';
|
import {requireAuth} from '../middleware/auth.js';
|
||||||
@@ -118,7 +119,7 @@ export class Events {
|
|||||||
const usage = await EventService.getEventUsage(auth.projectId!, eventName);
|
const usage = await EventService.getEventUsage(auth.projectId!, eventName);
|
||||||
return res.status(200).json(usage);
|
return res.status(200).json(usage);
|
||||||
} catch (error) {
|
} 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({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to get event usage',
|
error: error instanceof Error ? error.message : 'Failed to get event usage',
|
||||||
});
|
});
|
||||||
@@ -145,7 +146,7 @@ export class Events {
|
|||||||
const result = await EventService.deleteEvent(auth.projectId!, eventName);
|
const result = await EventService.deleteEvent(auth.projectId!, eventName);
|
||||||
return res.status(200).json(result);
|
return res.status(200).json(result);
|
||||||
} catch (error) {
|
} 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({
|
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to delete event',
|
error: error instanceof Error ? error.message : 'Failed to delete event',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export class Github {
|
|||||||
}
|
}
|
||||||
const {code} = req.query;
|
const {code} = req.query;
|
||||||
|
|
||||||
|
if (!code || typeof code !== 'string') {
|
||||||
|
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
|
||||||
|
}
|
||||||
|
|
||||||
const data = new URLSearchParams({
|
const data = new URLSearchParams({
|
||||||
client_id: GITHUB_OAUTH_CLIENT,
|
client_id: GITHUB_OAUTH_CLIENT,
|
||||||
client_secret: GITHUB_OAUTH_SECRET,
|
client_secret: GITHUB_OAUTH_SECRET,
|
||||||
@@ -47,19 +51,33 @@ export class Github {
|
|||||||
redirect_uri: `${API_URI}/oauth/github/callback`,
|
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',
|
method: 'POST',
|
||||||
headers: {'Content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'},
|
headers: {'Content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'},
|
||||||
body: data,
|
body: data,
|
||||||
}).then(res => res.json());
|
}).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`, {
|
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());
|
}).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;
|
let isNewUser = false;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export class Google {
|
|||||||
}
|
}
|
||||||
const {code} = req.query;
|
const {code} = req.query;
|
||||||
|
|
||||||
|
if (!code || typeof code !== 'string') {
|
||||||
|
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
|
||||||
|
}
|
||||||
|
|
||||||
const data = new URLSearchParams({
|
const data = new URLSearchParams({
|
||||||
client_id: GOOGLE_OAUTH_CLIENT,
|
client_id: GOOGLE_OAUTH_CLIENT,
|
||||||
client_secret: GOOGLE_OAUTH_SECRET,
|
client_secret: GOOGLE_OAUTH_SECRET,
|
||||||
@@ -43,15 +47,25 @@ export class Google {
|
|||||||
grant_type: 'authorization_code',
|
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',
|
method: 'POST',
|
||||||
headers: {'Content-type': 'application/x-www-form-urlencoded'},
|
headers: {'Content-type': 'application/x-www-form-urlencoded'},
|
||||||
body: data,
|
body: data,
|
||||||
}).then(res => res.json());
|
}).then(res => res.json());
|
||||||
|
|
||||||
const {email} = await fetch(`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${access_token}`).then(
|
if (!tokenResponse.access_token) {
|
||||||
res => res.json(),
|
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 user = await UserService.email(email);
|
||||||
let isNewUser = false;
|
let isNewUser = false;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
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 {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {requireAuth} from '../middleware/auth.js';
|
import {requireAuth} from '../middleware/auth.js';
|
||||||
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
@Controller('projects')
|
@Controller('projects')
|
||||||
@@ -19,7 +20,7 @@ export class Projects {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
const membership = await prisma.membership.findFirst({
|
||||||
@@ -83,6 +84,38 @@ export class Projects {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project security metrics
|
||||||
|
* GET /projects/:id/security
|
||||||
|
*/
|
||||||
|
@Get(':id/security')
|
||||||
|
@Middleware([requireAuth])
|
||||||
|
@CatchAsync
|
||||||
|
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth as AuthResponse;
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing SecurityService
|
||||||
|
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: metrics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all members of a project
|
* Get all members of a project
|
||||||
* GET /projects/:id/members
|
* GET /projects/:id/members
|
||||||
@@ -92,7 +125,7 @@ export class Projects {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
const membership = await prisma.membership.findFirst({
|
||||||
@@ -141,7 +174,7 @@ export class Projects {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Validate params
|
// Validate params
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Controller, Middleware, Post} from '@overnightjs/core';
|
import {Controller, Middleware, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {requireAuth} from '../middleware/auth.js';
|
import {requireAuth} from '../middleware/auth.js';
|
||||||
@@ -66,7 +67,7 @@ export class Uploads {
|
|||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[UPLOADS] Failed to upload image:', error);
|
signale.error('[UPLOADS] Failed to upload image:', error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to upload image',
|
error: error instanceof Error ? error.message : 'Failed to upload image',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {randomBytes} from 'node:crypto';
|
import {randomBytes} from 'node:crypto';
|
||||||
|
|
||||||
import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
|
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 type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
|
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
|
||||||
import {stripe} from '../app/stripe.js';
|
import {stripe} from '../app/stripe.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {NotAuthenticated, NotFound} from '../exceptions/index.js';
|
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {isAuthenticated} from '../middleware/auth.js';
|
import {isAuthenticated} from '../middleware/auth.js';
|
||||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||||
@@ -63,6 +63,16 @@ export class Users {
|
|||||||
throw new NotAuthenticated();
|
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);
|
const {name} = ProjectSchemas.create.parse(req.body);
|
||||||
|
|
||||||
// Generate unique API keys
|
// Generate unique API keys
|
||||||
@@ -95,7 +105,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const data = ProjectSchemas.update.parse(req.body);
|
const data = ProjectSchemas.update.parse(req.body);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
@@ -127,7 +137,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has admin/owner access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
const membership = await prisma.membership.findFirst({
|
||||||
@@ -179,7 +189,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -258,7 +268,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -308,7 +318,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -341,7 +351,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -368,11 +378,14 @@ export class Users {
|
|||||||
throw new NotFound('Project not found or you do not have permission to update billing limits');
|
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({
|
const project = await prisma.project.findUnique({
|
||||||
where: {id},
|
where: {id},
|
||||||
select: {
|
select: {
|
||||||
subscription: true,
|
subscription: true,
|
||||||
|
billingLimitWorkflows: true,
|
||||||
|
billingLimitCampaigns: true,
|
||||||
|
billingLimitTransactional: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -395,8 +408,17 @@ export class Users {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalidate cache so new limits take effect immediately
|
// Clear notification cache keys for limits that changed
|
||||||
await BillingLimitService.invalidateCache(id);
|
// 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,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
||||||
|
|
||||||
@@ -408,7 +430,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -534,7 +556,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -622,7 +644,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -655,7 +677,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -680,6 +702,16 @@ export class Users {
|
|||||||
throw new NotFound('Project not found or you do not have permission to reset it');
|
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
|
// Delete all project data in a transaction
|
||||||
await prisma.$transaction(async tx => {
|
await prisma.$transaction(async tx => {
|
||||||
// Delete all emails
|
// Delete all emails
|
||||||
@@ -731,7 +763,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -758,13 +790,14 @@ export class Users {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get project to check for active subscription
|
// Get project to check for active subscription and disabled status
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: {id},
|
where: {id},
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
subscription: true,
|
subscription: true,
|
||||||
customer: true,
|
customer: true,
|
||||||
|
disabled: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -772,6 +805,15 @@ export class Users {
|
|||||||
throw new NotFound('Project not found');
|
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 project has an active subscription, cancel it first
|
||||||
if (STRIPE_ENABLED && stripe && project.subscription) {
|
if (STRIPE_ENABLED && stripe && project.subscription) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||||
import {WorkflowExecutionStatus} from '@plunk/db';
|
import {WorkflowExecutionStatus} from '@plunk/db';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {requireAuth} from '../middleware/auth.js';
|
import {requireAuth} from '../middleware/auth.js';
|
||||||
@@ -45,7 +46,7 @@ export class Workflows {
|
|||||||
|
|
||||||
return res.status(200).json(result);
|
return res.status(200).json(result);
|
||||||
} catch (error) {
|
} 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({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {CampaignService} from '../services/CampaignService.js';
|
import {CampaignService} from '../services/CampaignService.js';
|
||||||
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
|
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
|
||||||
@@ -14,11 +15,11 @@ export function createCampaignWorker() {
|
|||||||
async (job: Job<CampaignBatchJobData>) => {
|
async (job: Job<CampaignBatchJobData>) => {
|
||||||
const {campaignId, batchNumber, offset, limit, cursor} = job.data;
|
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);
|
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,
|
connection: campaignQueue.opts.connection,
|
||||||
@@ -27,15 +28,15 @@ export function createCampaignWorker() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
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) => {
|
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 => {
|
worker.on('error', err => {
|
||||||
console.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
|
signale.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return worker;
|
return worker;
|
||||||
|
|||||||
@@ -3,18 +3,50 @@
|
|||||||
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
|
* 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 Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {EmailService} from '../services/EmailService.js';
|
import {EmailService} from '../services/EmailService.js';
|
||||||
import {EventService} from '../services/EventService.js';
|
import {EventService} from '../services/EventService.js';
|
||||||
import {MeterService} from '../services/MeterService.js';
|
import {MeterService} from '../services/MeterService.js';
|
||||||
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
|
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
|
||||||
import {sendRawEmail} from '../services/SESService.js';
|
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
|
||||||
import {DASHBOARD_URI} from '../app/constants.js';
|
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.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>(
|
const worker = new Worker<SendEmailJobData>(
|
||||||
emailQueue.name,
|
emailQueue.name,
|
||||||
async (job: Job<SendEmailJobData>) => {
|
async (job: Job<SendEmailJobData>) => {
|
||||||
@@ -38,7 +70,7 @@ export function createEmailWorker() {
|
|||||||
|
|
||||||
// Check if project is disabled
|
// Check if project is disabled
|
||||||
if (email.project.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({
|
await prisma.email.update({
|
||||||
where: {id: emailId},
|
where: {id: emailId},
|
||||||
data: {
|
data: {
|
||||||
@@ -138,8 +170,59 @@ export function createEmailWorker() {
|
|||||||
sourceType: email.sourceType,
|
sourceType: email.sourceType,
|
||||||
sentAt: new Date().toISOString(),
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
} 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
|
// Mark as failed
|
||||||
await prisma.email.update({
|
await prisma.email.update({
|
||||||
@@ -157,22 +240,22 @@ export function createEmailWorker() {
|
|||||||
connection: emailQueue.opts.connection,
|
connection: emailQueue.opts.connection,
|
||||||
concurrency: 10, // Process up to 10 emails concurrently
|
concurrency: 10, // Process up to 10 emails concurrently
|
||||||
limiter: {
|
limiter: {
|
||||||
max: 25, // Max 25 emails per second
|
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
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) => {
|
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 => {
|
worker.on('error', err => {
|
||||||
console.error('[EMAIL-PROCESSOR] Worker error:', err);
|
signale.error('[EMAIL-PROCESSOR] Worker error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return worker;
|
return worker;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
import {parse} from 'csv-parse/sync';
|
import {parse} from 'csv-parse/sync';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {ContactService} from '../services/ContactService.js';
|
import {ContactService} from '../services/ContactService.js';
|
||||||
@@ -28,7 +29,7 @@ export function createImportWorker() {
|
|||||||
async (job: Job<ContactImportJobData>) => {
|
async (job: Job<ContactImportJobData>) => {
|
||||||
const {projectId, csvData, filename} = job.data;
|
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
|
// Fetch project information for notifications
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -66,7 +67,7 @@ export function createImportWorker() {
|
|||||||
throw new Error('CSV file is empty');
|
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
|
// Notify that import has started
|
||||||
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
|
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
|
||||||
@@ -151,7 +152,7 @@ export function createImportWorker() {
|
|||||||
await job.updateProgress(progress);
|
await job.updateProgress(progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
signale.info(
|
||||||
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
|
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -168,7 +169,7 @@ export function createImportWorker() {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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
|
// Notify that import has failed
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -191,15 +192,15 @@ export function createImportWorker() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
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) => {
|
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 => {
|
worker.on('error', err => {
|
||||||
console.error('[IMPORT-PROCESSOR] Worker error:', err);
|
signale.error('[IMPORT-PROCESSOR] Worker error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return worker;
|
return worker;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import {CampaignStatus} from '@plunk/db';
|
import {CampaignStatus} from '@plunk/db';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {CampaignService} from '../services/CampaignService.js';
|
import {CampaignService} from '../services/CampaignService.js';
|
||||||
@@ -16,7 +17,7 @@ export function createScheduledCampaignWorker() {
|
|||||||
async (job: Job<ScheduledCampaignJobData>) => {
|
async (job: Job<ScheduledCampaignJobData>) => {
|
||||||
const {campaignId} = job.data;
|
const {campaignId} = job.data;
|
||||||
|
|
||||||
console.log(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
|
signale.info(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
|
||||||
|
|
||||||
// Get campaign with project
|
// Get campaign with project
|
||||||
const campaign = await prisma.campaign.findUnique({
|
const campaign = await prisma.campaign.findUnique({
|
||||||
@@ -29,13 +30,13 @@ export function createScheduledCampaignWorker() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
console.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
|
signale.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if project is disabled
|
// Check if project is disabled
|
||||||
if (campaign.project.disabled) {
|
if (campaign.project.disabled) {
|
||||||
console.warn(
|
signale.warn(
|
||||||
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
|
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
|
||||||
);
|
);
|
||||||
await prisma.campaign.update({
|
await prisma.campaign.update({
|
||||||
@@ -47,7 +48,7 @@ export function createScheduledCampaignWorker() {
|
|||||||
|
|
||||||
// Verify campaign is still in SCHEDULED status
|
// Verify campaign is still in SCHEDULED status
|
||||||
if (campaign.status !== CampaignStatus.SCHEDULED) {
|
if (campaign.status !== CampaignStatus.SCHEDULED) {
|
||||||
console.warn(
|
signale.warn(
|
||||||
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
|
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -56,7 +57,7 @@ export function createScheduledCampaignWorker() {
|
|||||||
// Start sending the campaign
|
// Start sending the campaign
|
||||||
await CampaignService.startSending(campaign.projectId, campaignId);
|
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,
|
connection: scheduledQueue.opts.connection,
|
||||||
@@ -65,15 +66,15 @@ export function createScheduledCampaignWorker() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
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) => {
|
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 => {
|
worker.on('error', err => {
|
||||||
console.error('[SCHEDULED-PROCESSOR] Worker error:', err);
|
signale.error('[SCHEDULED-PROCESSOR] Worker error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return worker;
|
return worker;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async function startWorkers() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Start email worker
|
// Start email worker
|
||||||
const emailWorker = createEmailWorker();
|
const emailWorker = await createEmailWorker();
|
||||||
workers.push({name: 'email', worker: emailWorker});
|
workers.push({name: 'email', worker: emailWorker});
|
||||||
signale.success('[WORKER] Email worker started');
|
signale.success('[WORKER] Email worker started');
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
|
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
|
||||||
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
|
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
|
||||||
@@ -33,15 +34,15 @@ export function createWorkflowWorker() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
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) => {
|
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 => {
|
worker.on('error', err => {
|
||||||
console.error('[WORKFLOW-PROCESSOR] Worker error:', err);
|
signale.error('[WORKFLOW-PROCESSOR] Worker error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return worker;
|
return worker;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const jwt = {
|
|||||||
* @param request The express request object
|
* @param request The express request object
|
||||||
*/
|
*/
|
||||||
export function parseJwt(request: Request): string {
|
export function parseJwt(request: Request): string {
|
||||||
const token: string | undefined = request.cookies.token;
|
const token: string | undefined = request.cookies.next_token;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type {Prisma} from '@plunk/db';
|
import type {Prisma} from '@plunk/db';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {Keys} from './keys.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activity types that can be tracked
|
* Activity types that can be tracked
|
||||||
@@ -170,7 +172,7 @@ export class ActivityService {
|
|||||||
*/
|
*/
|
||||||
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
|
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
|
||||||
// Try to get from cache
|
// 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 {
|
try {
|
||||||
const cached = await redis.get(cacheKey);
|
const cached = await redis.get(cacheKey);
|
||||||
@@ -178,7 +180,7 @@ export class ActivityService {
|
|||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
// Default date range to last 30 days if not specified
|
||||||
@@ -241,7 +243,7 @@ export class ActivityService {
|
|||||||
try {
|
try {
|
||||||
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
|
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[ACTIVITY] Failed to cache stats:', error);
|
signale.warn('[ACTIVITY] Failed to cache stats:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
@@ -261,7 +263,7 @@ export class ActivityService {
|
|||||||
await redis.del(...keys);
|
await redis.del(...keys);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
|
signale.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {Keys} from './keys.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Time series data point for analytics
|
* Time series data point for analytics
|
||||||
@@ -57,7 +58,7 @@ export class AnalyticsService {
|
|||||||
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
|
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
|
||||||
|
|
||||||
// Check cache first
|
// 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);
|
const cached = await redis.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
@@ -190,7 +191,7 @@ export class AnalyticsService {
|
|||||||
const effectiveEndDate = endDate || now;
|
const effectiveEndDate = endDate || now;
|
||||||
|
|
||||||
// Check cache
|
// 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);
|
const cached = await redis.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
@@ -294,7 +295,7 @@ export class AnalyticsService {
|
|||||||
const effectiveEndDate = endDate || now;
|
const effectiveEndDate = endDate || now;
|
||||||
|
|
||||||
// Check cache
|
// 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);
|
const cached = await redis.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import {EmailSourceType} from '@plunk/db';
|
import {EmailSourceType} from '@plunk/db';
|
||||||
|
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
|
||||||
|
import React from 'react';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
|
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||||
import {stripe} from '../app/stripe.js';
|
import {stripe} from '../app/stripe.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {Keys} from './keys.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -221,7 +225,8 @@ export class BillingLimitService {
|
|||||||
project.billingLimitTransactional !== null;
|
project.billingLimitTransactional !== null;
|
||||||
|
|
||||||
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
// 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 totalUsage = await this.getTotalUsage(projectId);
|
||||||
const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
|
const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
|
||||||
const percentage = (totalUsage / freeLimit) * 100;
|
const percentage = (totalUsage / freeLimit) * 100;
|
||||||
@@ -236,6 +241,9 @@ export class BillingLimitService {
|
|||||||
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Send email notification
|
||||||
|
await this.sendLimitExceededEmail(projectId, project.name, totalUsage, freeLimit, 'Free Tier (All Types)');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
warning: false,
|
warning: false,
|
||||||
@@ -257,6 +265,16 @@ export class BillingLimitService {
|
|||||||
percentage,
|
percentage,
|
||||||
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
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 {
|
return {
|
||||||
@@ -297,6 +315,9 @@ export class BillingLimitService {
|
|||||||
if (project) {
|
if (project) {
|
||||||
// Send notification about limit exceeded
|
// Send notification about limit exceeded
|
||||||
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
|
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
|
||||||
|
|
||||||
|
// Send email notification
|
||||||
|
await this.sendLimitExceededEmail(projectId, project.name, usage, limit, sourceType);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -320,7 +341,17 @@ export class BillingLimitService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (project) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +446,8 @@ export class BillingLimitService {
|
|||||||
project.billingLimitTransactional !== null;
|
project.billingLimitTransactional !== null;
|
||||||
|
|
||||||
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
||||||
if (!project.subscription && !hasCustomLimits) {
|
// Only show free tier limits if billing is enabled
|
||||||
|
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
|
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
|
||||||
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
||||||
const percentage = (totalUsage / limit) * 100;
|
const percentage = (totalUsage / limit) * 100;
|
||||||
@@ -455,7 +487,11 @@ export class BillingLimitService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate usage cache for a project
|
* 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
|
* @param projectId - Project ID
|
||||||
*/
|
*/
|
||||||
@@ -474,6 +510,69 @@ 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;
|
||||||
|
},
|
||||||
|
newLimits: {
|
||||||
|
workflows: number | null;
|
||||||
|
campaigns: number | null;
|
||||||
|
transactional: 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
* Get Redis cache key for usage count
|
||||||
*/
|
*/
|
||||||
@@ -481,7 +580,7 @@ export class BillingLimitService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
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 +592,110 @@ export class BillingLimitService {
|
|||||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
return {start, end};
|
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);
|
||||||
|
|
||||||
|
const alreadySent = await redis.get(cacheKey);
|
||||||
|
if (alreadySent === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await prisma.membership.findMany({
|
||||||
|
where: {projectId},
|
||||||
|
include: {user: {select: {email: true}}},
|
||||||
|
});
|
||||||
|
const emails = members.map(m => m.user.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)));
|
||||||
|
|
||||||
|
// Mark that we've sent the warning email (expires at end of month)
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
await redis.setex(cacheKey, ttl, '1');
|
||||||
|
} catch (error) {
|
||||||
|
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
const alreadySent = await redis.get(cacheKey);
|
||||||
|
if (alreadySent === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await prisma.membership.findMany({
|
||||||
|
where: {projectId},
|
||||||
|
include: {user: {select: {email: true}}},
|
||||||
|
});
|
||||||
|
const emails = members.map(m => m.user.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)));
|
||||||
|
|
||||||
|
// Mark that we've sent the limit email (expires at end of month)
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
await redis.setex(cacheKey, ttl, '1');
|
||||||
|
} catch (error) {
|
||||||
|
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
||||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
|
||||||
import type {FilterCondition} from '@plunk/types';
|
import type {FilterCondition} from '@plunk/types';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
||||||
|
|
||||||
|
import {BillingLimitService} from './BillingLimitService.js';
|
||||||
import {DomainService} from './DomainService.js';
|
import {DomainService} from './DomainService.js';
|
||||||
import {EmailService} from './EmailService.js';
|
import {EmailService} from './EmailService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {QueueService} from './QueueService.js';
|
import {QueueService} from './QueueService.js';
|
||||||
import {SegmentService} from './SegmentService.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';
|
import {sendRawEmail} from './SESService.js';
|
||||||
|
|
||||||
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
|
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
|
||||||
@@ -22,8 +24,8 @@ export interface CreateCampaignData {
|
|||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
from: string;
|
from: string;
|
||||||
fromName?: string;
|
fromName?: string | null;
|
||||||
replyTo?: string;
|
replyTo?: string | null;
|
||||||
audienceType: CampaignAudienceType;
|
audienceType: CampaignAudienceType;
|
||||||
audienceCondition?: FilterCondition;
|
audienceCondition?: FilterCondition;
|
||||||
segmentId?: string;
|
segmentId?: string;
|
||||||
@@ -35,8 +37,8 @@ export interface UpdateCampaignData {
|
|||||||
subject?: string;
|
subject?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
fromName?: string;
|
fromName?: string | null;
|
||||||
replyTo?: string;
|
replyTo?: string | null;
|
||||||
audienceType?: CampaignAudienceType;
|
audienceType?: CampaignAudienceType;
|
||||||
audienceCondition?: FilterCondition;
|
audienceCondition?: FilterCondition;
|
||||||
segmentId?: string;
|
segmentId?: string;
|
||||||
@@ -71,7 +73,7 @@ export class CampaignService {
|
|||||||
SegmentService.validateCondition(data.audienceCondition);
|
SegmentService.validateCondition(data.audienceCondition);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create campaign
|
// Create campaign with initial recipient count of 0
|
||||||
const campaign = await prisma.campaign.create({
|
const campaign = await prisma.campaign.create({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -86,6 +88,7 @@ export class CampaignService {
|
|||||||
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
|
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
|
||||||
segmentId: data.segmentId,
|
segmentId: data.segmentId,
|
||||||
status: CampaignStatus.DRAFT,
|
status: CampaignStatus.DRAFT,
|
||||||
|
totalRecipients: 0, // Will be updated below
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
@@ -94,10 +97,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
|
// Send notification about campaign creation
|
||||||
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
|
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
|
||||||
|
|
||||||
return campaign;
|
return updatedCampaign;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,10 +157,25 @@ export class CampaignService {
|
|||||||
delete (updateData as Record<string, unknown>).segmentId;
|
delete (updateData as Record<string, unknown>).segmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return prisma.campaign.update({
|
// Update the campaign first
|
||||||
|
const updatedCampaign = await prisma.campaign.update({
|
||||||
where: {id: campaignId},
|
where: {id: campaignId},
|
||||||
data: updateData,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -252,7 +277,7 @@ export class CampaignService {
|
|||||||
const campaign = await this.get(projectId, campaignId);
|
const campaign = await this.get(projectId, campaignId);
|
||||||
|
|
||||||
// Create a new campaign with the same data but reset status and stats
|
// Create a new campaign with the same data but reset status and stats
|
||||||
return prisma.campaign.create({
|
const duplicatedCampaign = await prisma.campaign.create({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
name: `${campaign.name} (Copy)`,
|
name: `${campaign.name} (Copy)`,
|
||||||
@@ -266,7 +291,7 @@ export class CampaignService {
|
|||||||
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
||||||
segmentId: campaign.segmentId,
|
segmentId: campaign.segmentId,
|
||||||
status: CampaignStatus.DRAFT,
|
status: CampaignStatus.DRAFT,
|
||||||
totalRecipients: 0,
|
totalRecipients: 0, // Will be updated below
|
||||||
sentCount: 0,
|
sentCount: 0,
|
||||||
deliveredCount: 0,
|
deliveredCount: 0,
|
||||||
openedCount: 0,
|
openedCount: 0,
|
||||||
@@ -274,6 +299,13 @@ export class CampaignService {
|
|||||||
bouncedCount: 0,
|
bouncedCount: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Calculate and update recipient count
|
||||||
|
const recipientCount = await this.getRecipientCount(projectId, duplicatedCampaign);
|
||||||
|
return prisma.campaign.update({
|
||||||
|
where: {id: duplicatedCampaign.id},
|
||||||
|
data: {totalRecipients: recipientCount},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -294,6 +326,24 @@ export class CampaignService {
|
|||||||
throw new HttpException(400, 'Campaign has no recipients');
|
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) {
|
if (scheduledFor) {
|
||||||
// Schedule for later
|
// Schedule for later
|
||||||
if (scheduledFor.getTime() <= Date.now()) {
|
if (scheduledFor.getTime() <= Date.now()) {
|
||||||
@@ -412,7 +462,7 @@ export class CampaignService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (campaign.status !== CampaignStatus.SENDING) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,21 +508,11 @@ export class CampaignService {
|
|||||||
replyTo: campaign.replyTo || undefined,
|
replyTo: campaign.replyTo || undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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
|
// 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
|
// Queue next batch if there are more contacts
|
||||||
if (hasMore && nextCursor) {
|
if (hasMore && nextCursor) {
|
||||||
await QueueService.queueCampaignBatch({
|
await QueueService.queueCampaignBatch({
|
||||||
@@ -482,27 +522,6 @@ export class CampaignService {
|
|||||||
limit,
|
limit,
|
||||||
cursor: nextCursor,
|
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ export class EmailService {
|
|||||||
sentAt: new Date().toISOString(),
|
sentAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[EMAIL] Failed to send email ${emailId}:`, error);
|
signale.error(`[EMAIL] Failed to send email ${emailId}:`, error);
|
||||||
|
|
||||||
// Mark as failed
|
// Mark as failed
|
||||||
await prisma.email.update({
|
await prisma.email.update({
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type {Event} from '@plunk/db';
|
import type {Event} from '@plunk/db';
|
||||||
import {Prisma} from '@plunk/db';
|
import {Prisma} from '@plunk/db';
|
||||||
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {ValidationError} from '../exceptions/index.js';
|
||||||
|
import {Keys} from './keys.js';
|
||||||
|
|
||||||
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
||||||
|
|
||||||
@@ -48,11 +51,11 @@ export class EventService {
|
|||||||
* Should be called when workflows are enabled/disabled or updated
|
* Should be called when workflows are enabled/disabled or updated
|
||||||
*/
|
*/
|
||||||
public static async invalidateWorkflowCache(projectId: string): Promise<void> {
|
public static async invalidateWorkflowCache(projectId: string): Promise<void> {
|
||||||
const cacheKey = `workflows:enabled:${projectId}`;
|
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||||
try {
|
try {
|
||||||
await redis.del(cacheKey);
|
await redis.del(cacheKey);
|
||||||
} catch (error) {
|
} 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
|
* @param eventName - The event name to delete
|
||||||
*/
|
*/
|
||||||
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
|
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
|
||||||
// Prevent deletion of system events
|
// Prevent deletion of reserved system events
|
||||||
if (eventName.startsWith('email.') || eventName.startsWith('segment.')) {
|
if (this.isReservedEvent(eventName)) {
|
||||||
throw new Error('Cannot delete system events (email.* or segment.*)');
|
throw new Error(`Cannot delete reserved system event: ${eventName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if event is in use
|
// Check if event is in use
|
||||||
@@ -311,6 +314,36 @@ export class EventService {
|
|||||||
return {deletedCount: result.count};
|
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
|
* Trigger workflows based on an event
|
||||||
* Uses Redis caching for enabled workflows to improve performance
|
* Uses Redis caching for enabled workflows to improve performance
|
||||||
@@ -322,7 +355,7 @@ export class EventService {
|
|||||||
data?: Record<string, unknown>,
|
data?: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Try to get workflows from cache
|
// Try to get workflows from cache
|
||||||
const cacheKey = `workflows:enabled:${projectId}`;
|
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||||
let workflows;
|
let workflows;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -331,7 +364,7 @@ export class EventService {
|
|||||||
workflows = JSON.parse(cached);
|
workflows = JSON.parse(cached);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
// If not in cache, fetch from database
|
||||||
@@ -353,7 +386,7 @@ export class EventService {
|
|||||||
try {
|
try {
|
||||||
await redis.setex(cacheKey, 300, JSON.stringify(workflows));
|
await redis.setex(cacheKey, 300, JSON.stringify(workflows));
|
||||||
} catch (error) {
|
} 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 {
|
} else {
|
||||||
// If event is not contact-specific, you might want different logic
|
// If event is not contact-specific, you might want different logic
|
||||||
// For example, trigger for all contacts, or skip
|
// 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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +462,7 @@ export class EventService {
|
|||||||
const triggerStep = workflow.steps[0];
|
const triggerStep = workflow.steps[0];
|
||||||
|
|
||||||
if (!triggerStep) {
|
if (!triggerStep) {
|
||||||
console.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
|
signale.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,14 +477,14 @@ export class EventService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
signale.info(
|
||||||
`[EVENT] Started workflow ${workflowId} execution ${execution.id} for contact ${contactId}${workflow.allowReentry ? ' (re-entry allowed)' : ''}`,
|
`[EVENT] Started workflow ${workflowId} execution ${execution.id} for contact ${contactId}${workflow.allowReentry ? ' (re-entry allowed)' : ''}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start executing the workflow
|
// Start executing the workflow
|
||||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
|
signale.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
PutBucketPolicyCommand,
|
PutBucketPolicyCommand,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
S3_ENDPOINT,
|
S3_ENDPOINT,
|
||||||
S3_ACCESS_KEY_ID,
|
S3_ACCESS_KEY_ID,
|
||||||
@@ -69,13 +71,13 @@ export async function initializeBucket(): Promise<void> {
|
|||||||
Bucket: S3_BUCKET,
|
Bucket: S3_BUCKET,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
console.log(`[S3] Created bucket: ${S3_BUCKET}`);
|
signale.info(`[S3] Created bucket: ${S3_BUCKET}`);
|
||||||
} catch (createError) {
|
} catch (createError) {
|
||||||
console.error('[S3] Failed to create bucket:', createError);
|
signale.error('[S3] Failed to create bucket:', createError);
|
||||||
throw createError;
|
throw createError;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('[S3] Failed to check bucket:', error);
|
signale.error('[S3] Failed to check bucket:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,10 +105,10 @@ export async function initializeBucket(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!bucketExists) {
|
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) {
|
} 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
|
// Don't throw - bucket was created but policy failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {SES} from '@aws-sdk/client-ses';
|
import {SES} from '@aws-sdk/client-ses';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AWS_SES_ACCESS_KEY_ID,
|
AWS_SES_ACCESS_KEY_ID,
|
||||||
@@ -6,7 +7,8 @@ import {
|
|||||||
AWS_SES_SECRET_ACCESS_KEY,
|
AWS_SES_SECRET_ACCESS_KEY,
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
SES_CONFIGURATION_SET,
|
SES_CONFIGURATION_SET,
|
||||||
SES_CONFIGURATION_SET_NO_TRACKING
|
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||||
|
TRACKING_TOGGLE_ENABLED
|
||||||
} from '../app/constants.js';
|
} from '../app/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,10 +162,15 @@ ${breakLongLines(attachment.content, 76, true)}`,
|
|||||||
: ''
|
: ''
|
||||||
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
|
}${mixedBoundary ? `\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
|
// Send via SES
|
||||||
const response = await ses.sendRawEmail({
|
const response = await ses.sendRawEmail({
|
||||||
Destinations: destinations,
|
Destinations: destinations,
|
||||||
ConfigurationSetName: tracking ? SES_CONFIGURATION_SET : SES_CONFIGURATION_SET_NO_TRACKING,
|
ConfigurationSetName: configurationSetName,
|
||||||
RawMessage: {
|
RawMessage: {
|
||||||
Data: new TextEncoder().encode(rawMessage),
|
Data: new TextEncoder().encode(rawMessage),
|
||||||
},
|
},
|
||||||
@@ -250,3 +257,26 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
|
|||||||
ForwardingEnabled: false,
|
ForwardingEnabled: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AWS SES account sending quota and rate limit
|
||||||
|
* @returns MaxSendRate (emails per second) or null if the call fails
|
||||||
|
*/
|
||||||
|
export const getSendingQuota = async (): Promise<{
|
||||||
|
maxSendRate: number;
|
||||||
|
max24HourSend: number;
|
||||||
|
sentLast24Hours: number;
|
||||||
|
} | null> => {
|
||||||
|
try {
|
||||||
|
const quota = await ses.getSendQuota({});
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxSendRate: quota.MaxSendRate ?? 14, // Default to sandbox limit if not provided
|
||||||
|
max24HourSend: quota.Max24HourSend ?? 200, // Default sandbox daily limit
|
||||||
|
sentLast24Hours: quota.SentLast24Hours ?? 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
signale.error('[SES] Failed to fetch sending quota:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
|
||||||
|
import React from 'react';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {Keys} from './keys.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {QueueService} from './QueueService.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
|
* Security thresholds for bounce and complaint rates
|
||||||
@@ -45,7 +49,6 @@ interface SecurityStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SecurityService {
|
export class SecurityService {
|
||||||
private static readonly CACHE_PREFIX = 'security';
|
|
||||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +57,7 @@ export class SecurityService {
|
|||||||
public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> {
|
public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> {
|
||||||
try {
|
try {
|
||||||
// Try to get from cache first
|
// Try to get from cache first
|
||||||
const cacheKey = this.getCacheKey(projectId, 'rates');
|
const cacheKey = Keys.Security.rates(projectId);
|
||||||
const cached = await redis.get(cacheKey);
|
const cached = await redis.get(cacheKey);
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -107,9 +110,31 @@ export class SecurityService {
|
|||||||
// Get current security status
|
// Get current security status
|
||||||
const status = await this.getSecurityStatus(projectId);
|
const status = await this.getSecurityStatus(projectId);
|
||||||
|
|
||||||
// If project should be disabled, disable it
|
// If project should be disabled, disable it (only if auto-disable is enabled)
|
||||||
if (status.shouldDisable) {
|
if (status.shouldDisable && AUTO_PROJECT_DISABLE) {
|
||||||
await this.disableProject(projectId, status);
|
await this.disableProject(projectId, status);
|
||||||
|
} else if (status.shouldDisable && !AUTO_PROJECT_DISABLE) {
|
||||||
|
// Log critical violations but don't auto-disable (self-hosted mode)
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: {id: projectId},
|
||||||
|
select: {name: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (project) {
|
||||||
|
signale.error(
|
||||||
|
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
|
||||||
|
status.violations,
|
||||||
|
);
|
||||||
|
signale.info(
|
||||||
|
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
|
||||||
|
);
|
||||||
|
signale.info(
|
||||||
|
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send notification about critical security violations
|
||||||
|
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
|
||||||
|
}
|
||||||
} else if (status.warnings.length > 0) {
|
} else if (status.warnings.length > 0) {
|
||||||
// Log warnings for monitoring
|
// Log warnings for monitoring
|
||||||
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
|
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
|
||||||
@@ -137,13 +162,55 @@ export class SecurityService {
|
|||||||
*/
|
*/
|
||||||
public static async invalidateCache(projectId: string): Promise<void> {
|
public static async invalidateCache(projectId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cacheKey = this.getCacheKey(projectId, 'rates');
|
const cacheKey = Keys.Security.rates(projectId);
|
||||||
await redis.del(cacheKey);
|
await redis.del(cacheKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, error);
|
signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user is a member of any disabled project
|
||||||
|
* Users with disabled projects cannot create new projects
|
||||||
|
*/
|
||||||
|
public static async userHasDisabledProject(userId: string): Promise<{
|
||||||
|
hasDisabledProject: boolean;
|
||||||
|
disabledProjectNames: string[];
|
||||||
|
}> {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific project is disabled
|
||||||
|
*/
|
||||||
|
public static async isProjectDisabled(projectId: string): Promise<boolean> {
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: {id: projectId},
|
||||||
|
select: {disabled: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
return project?.disabled ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a project's security metrics (for admin/dashboard display)
|
* Get a project's security metrics (for admin/dashboard display)
|
||||||
*/
|
*/
|
||||||
@@ -167,13 +234,6 @@ export class SecurityService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cache key for security metrics
|
|
||||||
*/
|
|
||||||
private static getCacheKey(projectId: string, type: 'rates'): string {
|
|
||||||
return `${this.CACHE_PREFIX}:${projectId}:${type}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate bounce and complaint rates for a project
|
* Calculate bounce and complaint rates for a project
|
||||||
*/
|
*/
|
||||||
@@ -345,6 +405,29 @@ export class SecurityService {
|
|||||||
|
|
||||||
// Send urgent notification about project suspension
|
// Send urgent notification about project suspension
|
||||||
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
|
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
|
||||||
|
|
||||||
|
// Send email notification to project members
|
||||||
|
try {
|
||||||
|
const members = await prisma.membership.findMany({
|
||||||
|
where: {projectId},
|
||||||
|
include: {user: {select: {email: true}}},
|
||||||
|
});
|
||||||
|
const emails = members.map(m => m.user.email);
|
||||||
|
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) {
|
} catch (error) {
|
||||||
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
|
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
||||||
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
@@ -276,7 +277,7 @@ export class SegmentService {
|
|||||||
data: {memberCount},
|
data: {memberCount},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to update count for segment ${segment.id}:`, error);
|
signale.error(`Failed to update count for segment ${segment.id}:`, error);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -383,7 +384,7 @@ export class SegmentService {
|
|||||||
segmentName: segment.name,
|
segmentName: segment.name,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 +415,7 @@ export class SegmentService {
|
|||||||
segmentName: segment.name,
|
segmentName: segment.name,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 +426,7 @@ export class SegmentService {
|
|||||||
data: {memberCount: matchingContactIds.size},
|
data: {memberCount: matchingContactIds.size},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
signale.info(
|
||||||
`[SEGMENT] Computed membership for segment ${segmentId}: added ${toAdd.length}, removed ${toRemove.length}, total ${matchingContactIds.size}`,
|
`[SEGMENT] Computed membership for segment ${segmentId}: added ${toAdd.length}, removed ${toRemove.length}, total ${matchingContactIds.size}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ export class TemplateService {
|
|||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
from: string;
|
from: string;
|
||||||
fromName?: string;
|
fromName?: string | null;
|
||||||
replyTo?: string;
|
replyTo?: string | null;
|
||||||
type?: Template['type'];
|
type?: Template['type'];
|
||||||
},
|
},
|
||||||
): Promise<Template> {
|
): Promise<Template> {
|
||||||
@@ -120,8 +120,8 @@ export class TemplateService {
|
|||||||
subject?: string;
|
subject?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
fromName?: string;
|
fromName?: string | null;
|
||||||
replyTo?: string;
|
replyTo?: string | null;
|
||||||
type?: Template['type'];
|
type?: Template['type'];
|
||||||
},
|
},
|
||||||
): Promise<Template> {
|
): Promise<Template> {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function getCookieDomain(): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UserService {
|
export class UserService {
|
||||||
public static readonly COOKIE_NAME = 'token';
|
public static readonly COOKIE_NAME = 'next_token';
|
||||||
|
|
||||||
public static async id(id: string) {
|
public static async id(id: string) {
|
||||||
return wrapRedis(Keys.User.id(id), async () => {
|
return wrapRedis(Keys.User.id(id), async () => {
|
||||||
@@ -52,6 +52,10 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async email(email: string) {
|
public static async email(email: string) {
|
||||||
|
if (!email) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return wrapRedis(Keys.User.email(email), async () => {
|
return wrapRedis(Keys.User.email(email), async () => {
|
||||||
return prisma.user.findFirst({
|
return prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
|
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
|
||||||
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
|
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
@@ -786,7 +787,7 @@ export class WorkflowService {
|
|||||||
// Start executing the workflow asynchronously
|
// Start executing the workflow asynchronously
|
||||||
// Don't await - let it run in background
|
// Don't await - let it run in background
|
||||||
WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => {
|
WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => {
|
||||||
console.error('Error executing workflow:', error);
|
signale.error('Error executing workflow:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
return execution;
|
return execution;
|
||||||
|
|||||||
@@ -5,6 +5,28 @@ import {EmailService} from '../EmailService';
|
|||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
import {redis} from '../../database/redis';
|
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', () => {
|
describe('BillingLimitService - Critical Enforcement', () => {
|
||||||
let projectId: string;
|
let projectId: string;
|
||||||
let contactId: string;
|
let contactId: string;
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import {describe, it, expect, beforeEach} from 'vitest';
|
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
||||||
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
|
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
|
||||||
import {CampaignService} from '../CampaignService';
|
import {CampaignService} from '../CampaignService';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
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', () => {
|
describe('CampaignService', () => {
|
||||||
let projectId: string;
|
let projectId: string;
|
||||||
const prisma = getPrismaClient();
|
const prisma = getPrismaClient();
|
||||||
@@ -356,4 +366,227 @@ describe('CampaignService', () => {
|
|||||||
expect(matching).toBe(5);
|
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, vi} from 'vitest';
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||||
import {ActionSchemas} from '@plunk/shared';
|
import {ActionSchemas} from '@plunk/shared';
|
||||||
import {EmailService} from '../EmailService';
|
import {EmailService} from '../EmailService';
|
||||||
@@ -669,7 +669,6 @@ describe('EmailService', () => {
|
|||||||
// ========================================
|
// ========================================
|
||||||
describe('Attachment Schema Validation', () => {
|
describe('Attachment Schema Validation', () => {
|
||||||
it('should validate attachment count limit (max 10)', () => {
|
it('should validate attachment count limit (max 10)', () => {
|
||||||
|
|
||||||
const tooManyAttachments = Array.from({length: 11}, (_, i) => ({
|
const tooManyAttachments = Array.from({length: 11}, (_, i) => ({
|
||||||
filename: `file${i}.txt`,
|
filename: `file${i}.txt`,
|
||||||
content: Buffer.from('content').toString('base64'),
|
content: Buffer.from('content').toString('base64'),
|
||||||
@@ -690,7 +689,6 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should validate attachment size limit (10MB total)', () => {
|
it('should validate attachment size limit (10MB total)', () => {
|
||||||
|
|
||||||
// Exceeds ~13.3M base64 chars limit
|
// Exceeds ~13.3M base64 chars limit
|
||||||
const largeContent = 'A'.repeat(14000000);
|
const largeContent = 'A'.repeat(14000000);
|
||||||
|
|
||||||
@@ -711,11 +709,11 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should accept attachments within size limit', () => {
|
it('should accept attachments within size limit', () => {
|
||||||
|
|
||||||
const validContent = Buffer.from('Small file content').toString('base64');
|
const validContent = Buffer.from('Small file content').toString('base64');
|
||||||
|
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: '[email protected]',
|
to: '[email protected]',
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
attachments: [
|
attachments: [
|
||||||
@@ -731,7 +729,6 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reject attachment with missing required fields', () => {
|
it('should reject attachment with missing required fields', () => {
|
||||||
|
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: '[email protected]',
|
to: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
@@ -748,7 +745,6 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reject attachment with empty filename', () => {
|
it('should reject attachment with empty filename', () => {
|
||||||
|
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: '[email protected]',
|
to: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
@@ -766,7 +762,6 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reject attachment with filename exceeding 255 chars', () => {
|
it('should reject attachment with filename exceeding 255 chars', () => {
|
||||||
|
|
||||||
const tooLongFilename = 'a'.repeat(256) + '.pdf';
|
const tooLongFilename = 'a'.repeat(256) + '.pdf';
|
||||||
|
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
@@ -786,18 +781,12 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should accept valid attachment with various content types', () => {
|
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) {
|
for (const contentType of contentTypes) {
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: '[email protected]',
|
to: '[email protected]',
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
attachments: [
|
attachments: [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {WorkflowExecutionStatus, WorkflowTriggerType} from '@plunk/db';
|
import {WorkflowExecutionStatus, WorkflowTriggerType} from '@plunk/db';
|
||||||
import {EventService} from '../EventService';
|
import {EventService} from '../EventService';
|
||||||
|
import {Keys} from '../keys';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
// Mock Redis for caching tests - must be inline to avoid hoisting issues
|
// Mock Redis for caching tests - must be inline to avoid hoisting issues
|
||||||
@@ -503,7 +504,7 @@ describe('EventService', () => {
|
|||||||
const {redis} = await import('../../database/redis');
|
const {redis} = await import('../../database/redis');
|
||||||
|
|
||||||
// Set cache
|
// Set cache
|
||||||
const cacheKey = `workflows:enabled:${projectId}`;
|
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||||
await redis.set(cacheKey, JSON.stringify([{id: 'test'}]));
|
await redis.set(cacheKey, JSON.stringify([{id: 'test'}]));
|
||||||
|
|
||||||
// Verify cache exists
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
|
import {WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
|
||||||
import {WorkflowService} from '../WorkflowService';
|
import {WorkflowService} from '../WorkflowService';
|
||||||
|
import {Keys} from '../keys';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
// Mock Redis for caching tests - must be inline to avoid hoisting issues
|
// 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 () => {
|
it('should invalidate cache when creating enabled workflow', async () => {
|
||||||
const {redis} = await import('../../database/redis');
|
const {redis} = await import('../../database/redis');
|
||||||
const cacheKey = `workflows:enabled:${projectId}`;
|
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||||
|
|
||||||
// Set cache
|
// Set cache
|
||||||
await redis.set(cacheKey, JSON.stringify([{id: 'old'}]));
|
await redis.set(cacheKey, JSON.stringify([{id: 'old'}]));
|
||||||
@@ -286,7 +287,7 @@ describe('WorkflowService', () => {
|
|||||||
|
|
||||||
it('should invalidate cache when enabling workflow', async () => {
|
it('should invalidate cache when enabling workflow', async () => {
|
||||||
const {redis} = await import('../../database/redis');
|
const {redis} = await import('../../database/redis');
|
||||||
const cacheKey = `workflows:enabled:${projectId}`;
|
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||||
|
|
||||||
const workflow = await factories.createWorkflow({
|
const workflow = await factories.createWorkflow({
|
||||||
projectId,
|
projectId,
|
||||||
@@ -326,7 +327,7 @@ describe('WorkflowService', () => {
|
|||||||
|
|
||||||
it('should invalidate cache when deleting enabled workflow', async () => {
|
it('should invalidate cache when deleting enabled workflow', async () => {
|
||||||
const {redis} = await import('../../database/redis');
|
const {redis} = await import('../../database/redis');
|
||||||
const cacheKey = `workflows:enabled:${projectId}`;
|
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||||
|
|
||||||
const workflow = await factories.createWorkflow({
|
const workflow = await factories.createWorkflow({
|
||||||
projectId,
|
projectId,
|
||||||
|
|||||||
@@ -15,4 +15,41 @@ export const Keys = {
|
|||||||
return `domain:project:${projectId}`;
|
return `domain:project:${projectId}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export function buildEmailFieldsUpdate(data: {
|
|||||||
subject?: string;
|
subject?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
fromName?: string;
|
fromName?: string | null;
|
||||||
replyTo?: string;
|
replyTo?: string | null;
|
||||||
}): Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput {
|
}): Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput {
|
||||||
return buildUpdateData(data) as Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput;
|
return buildUpdateData(data) as Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
"@plunk/shared": "*",
|
"@plunk/shared": "*",
|
||||||
"@plunk/ui": "*",
|
"@plunk/ui": "*",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.7",
|
"next": "^16.0.10",
|
||||||
"next-seo": "^6.6.0",
|
"next-seo": "^6.6.0",
|
||||||
"react": "19.2.1",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.3",
|
||||||
"sonner": "^2.0.6"
|
"sonner": "^2.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"clean": "rimraf node_modules .next .turbo"
|
"clean": "rimraf node_modules .next .turbo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.39.0",
|
||||||
"@plunk/db": "*",
|
"@plunk/db": "*",
|
||||||
"@plunk/shared": "*",
|
"@plunk/shared": "*",
|
||||||
"@plunk/ui": "*",
|
"@plunk/ui": "*",
|
||||||
@@ -19,6 +22,7 @@
|
|||||||
"@tiptap/extension-image": "^3.11.0",
|
"@tiptap/extension-image": "^3.11.0",
|
||||||
"@tiptap/extension-link": "^3.11.0",
|
"@tiptap/extension-link": "^3.11.0",
|
||||||
"@tiptap/extension-mention": "^3.11.0",
|
"@tiptap/extension-mention": "^3.11.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.11.0",
|
||||||
"@tiptap/extension-table": "^3.11.0",
|
"@tiptap/extension-table": "^3.11.0",
|
||||||
"@tiptap/extension-table-cell": "^3.11.0",
|
"@tiptap/extension-table-cell": "^3.11.0",
|
||||||
"@tiptap/extension-table-header": "^3.11.0",
|
"@tiptap/extension-table-header": "^3.11.0",
|
||||||
@@ -30,16 +34,18 @@
|
|||||||
"@tiptap/starter-kit": "^3.11.0",
|
"@tiptap/starter-kit": "^3.11.0",
|
||||||
"@tiptap/suggestion": "^3.11.0",
|
"@tiptap/suggestion": "^3.11.0",
|
||||||
"@types/dagre": "^0.7.53",
|
"@types/dagre": "^0.7.53",
|
||||||
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"@xyflow/react": "^12.9.3",
|
"@xyflow/react": "^12.9.3",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"juice": "^11.0.3",
|
"juice": "^11.0.3",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.7",
|
"next": "^16.0.10",
|
||||||
"next-seo": "^6.6.0",
|
"next-seo": "^6.6.0",
|
||||||
"nuqs": "^2.7.3",
|
"nuqs": "^2.7.3",
|
||||||
"react": "19.2.1",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
"react-intl": "^7.1.14",
|
"react-intl": "^7.1.14",
|
||||||
"react-is": "^19.0.0",
|
"react-is": "^19.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {memo, useCallback, useEffect, useMemo, useState} from 'react';
|
import {memo, useEffect, useMemo, useState} from 'react';
|
||||||
import {useForm} from 'react-hook-form';
|
import {useForm} from 'react-hook-form';
|
||||||
import {zodResolver} from '@hookform/resolvers/zod';
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
import {BillingLimitSchemas} from '@plunk/shared';
|
import {BillingLimitSchemas} from '@plunk/shared';
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import {AlertCircle, AlertTriangle, Check} from 'lucide-react';
|
import {AlertCircle, AlertTriangle, Check} from 'lucide-react';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import type {z} from 'zod';
|
import type {z} from 'zod';
|
||||||
import {useBillingLimits, type BillingLimitsData, type CategoryLimit} from '../lib/hooks/useBillingLimits';
|
import {type BillingLimitsData, type CategoryLimit, useBillingLimits} from '../lib/hooks/useBillingLimits';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
|
|
||||||
// Price per email in the smallest currency unit (e.g., cents for USD/EUR)
|
// Price per email in the smallest currency unit (e.g., cents for USD/EUR)
|
||||||
@@ -50,7 +50,7 @@ const formatEmailCost = (emailCount: number, currency: string | null): string =>
|
|||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
}).format(costInCents / 100);
|
}).format(costInCents / 100);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Fallback if currency is invalid
|
// Fallback if currency is invalid
|
||||||
return `${(costInCents / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
return `${(costInCents / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
||||||
}
|
}
|
||||||
@@ -167,8 +167,8 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<div className="ml-2">
|
<div className="ml-2">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
You're on the free tier with 1,000 emails per month. Upgrade to a paid subscription for
|
You're on the free tier with 1,000 emails per month. Upgrade to a paid subscription for unlimited
|
||||||
unlimited emails or custom limits.
|
emails or custom limits.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -254,9 +254,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Maximum workflow emails per month. Leave empty for unlimited.
|
Maximum workflow emails per month. Leave empty for unlimited.
|
||||||
{estimatedCost && (
|
{estimatedCost && <span className="text-neutral-500"> ≈ {estimatedCost}/month</span>}
|
||||||
<span className="text-neutral-500"> ≈ {estimatedCost}/month</span>
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -286,9 +284,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Maximum campaign emails per month. Leave empty for unlimited.
|
Maximum campaign emails per month. Leave empty for unlimited.
|
||||||
{estimatedCost && (
|
{estimatedCost && <span className="text-neutral-500"> ≈ {estimatedCost}/month</span>}
|
||||||
<span className="text-neutral-500"> ≈ {estimatedCost}/month</span>
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -318,9 +314,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Maximum transactional emails per month. Leave empty for unlimited.
|
Maximum transactional emails per month. Leave empty for unlimited.
|
||||||
{estimatedCost && (
|
{estimatedCost && <span className="text-neutral-500"> ≈ {estimatedCost}/month</span>}
|
||||||
<span className="text-neutral-500"> ≈ {estimatedCost}/month</span>
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -412,7 +406,7 @@ const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: Usa
|
|||||||
<Alert className="mt-3 bg-red-50 border-red-200 text-red-900">
|
<Alert className="mt-3 bg-red-50 border-red-200 text-red-900">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<div className="ml-2">
|
<div className="ml-2">
|
||||||
<p className="text-xs">
|
<p className={'text-sm'}>
|
||||||
<strong>Limit reached:</strong> No more {category.toLowerCase()} emails can be sent this month.
|
<strong>Limit reached:</strong> No more {category.toLowerCase()} emails can be sent this month.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,7 +417,7 @@ const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: Usa
|
|||||||
<Alert className="mt-3 bg-orange-50 border-orange-200 text-orange-900">
|
<Alert className="mt-3 bg-orange-50 border-orange-200 text-orange-900">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<div className="ml-2">
|
<div className="ml-2">
|
||||||
<p className="text-xs">
|
<p className="text-sm">
|
||||||
<strong>Warning:</strong> You've used {Math.round(usage.percentage)}% of your{' '}
|
<strong>Warning:</strong> You've used {Math.round(usage.percentage)}% of your{' '}
|
||||||
{category.toLowerCase()} email limit.
|
{category.toLowerCase()} email limit.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Label,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@plunk/ui';
|
||||||
|
import type {Campaign} from '@plunk/db';
|
||||||
|
import {CampaignStatus} from '@plunk/db';
|
||||||
|
import {ArrowLeft, Calendar, Mail, Users} from 'lucide-react';
|
||||||
|
import {useState} from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
interface PaginatedCampaigns {
|
||||||
|
campaigns: Campaign[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignSelectionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSelectCampaign: (campaign: Campaign, selectedFields: SelectedFields) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedFields {
|
||||||
|
subject: boolean;
|
||||||
|
body: boolean;
|
||||||
|
from: boolean;
|
||||||
|
fromName: boolean;
|
||||||
|
replyTo: boolean;
|
||||||
|
audience: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: CampaignSelectionDialogProps) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||||
|
const [step, setStep] = useState<'select' | 'configure'>('select');
|
||||||
|
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
|
||||||
|
const [selectedFields, setSelectedFields] = useState<SelectedFields>({
|
||||||
|
subject: true,
|
||||||
|
body: true,
|
||||||
|
from: true,
|
||||||
|
fromName: true,
|
||||||
|
replyTo: true,
|
||||||
|
audience: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {data, isLoading} = useSWR<PaginatedCampaigns>(
|
||||||
|
open
|
||||||
|
? `/campaigns?page=${page}&pageSize=10${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`
|
||||||
|
: null,
|
||||||
|
{revalidateOnFocus: false},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCampaignClick = (campaign: Campaign) => {
|
||||||
|
setSelectedCampaign(campaign);
|
||||||
|
setStep('configure');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep('select');
|
||||||
|
setSelectedCampaign(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedCampaign) {
|
||||||
|
onSelectCampaign(selectedCampaign, selectedFields);
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset state
|
||||||
|
setPage(1);
|
||||||
|
setStatusFilter('ALL');
|
||||||
|
setStep('select');
|
||||||
|
setSelectedCampaign(null);
|
||||||
|
setSelectedFields({
|
||||||
|
subject: true,
|
||||||
|
body: true,
|
||||||
|
from: true,
|
||||||
|
fromName: true,
|
||||||
|
replyTo: true,
|
||||||
|
audience: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleField = (field: keyof SelectedFields) => {
|
||||||
|
setSelectedFields(prev => ({...prev, [field]: !prev[field]}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: CampaignStatus) => {
|
||||||
|
const variants: Record<
|
||||||
|
CampaignStatus,
|
||||||
|
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; className?: string}
|
||||||
|
> = {
|
||||||
|
DRAFT: {variant: 'secondary', label: 'Draft', className: 'bg-neutral-100 text-neutral-700'},
|
||||||
|
SCHEDULED: {variant: 'default', label: 'Scheduled', className: 'bg-blue-100 text-blue-700'},
|
||||||
|
SENDING: {variant: 'default', label: 'Sending', className: 'bg-purple-100 text-purple-700'},
|
||||||
|
SENT: {variant: 'default', label: 'Sent', className: 'bg-green-100 text-green-700'},
|
||||||
|
CANCELLED: {variant: 'destructive', label: 'Cancelled', className: 'bg-red-100 text-red-700'},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = variants[status];
|
||||||
|
return (
|
||||||
|
<Badge variant={config.variant} className={config.className}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAudienceLabel = (campaign: Campaign) => {
|
||||||
|
if (campaign.audienceType === 'ALL') {
|
||||||
|
return 'All Contacts';
|
||||||
|
} else if (campaign.audienceType === 'SEGMENT') {
|
||||||
|
return `Segment (${campaign.totalRecipients.toLocaleString()} contacts)`;
|
||||||
|
} else {
|
||||||
|
return `Filtered (${campaign.totalRecipients.toLocaleString()} contacts)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{step === 'configure' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleBack} className="h-8 w-8 p-0">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 'select' ? 'Select a Campaign' : 'Choose What to Copy'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{step === 'select'
|
||||||
|
? 'Choose a previous campaign to use as a starting point'
|
||||||
|
: 'Select which parts of the campaign you want to use'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 'select' ? (
|
||||||
|
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All Statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ALL">All Campaigns</SelectItem>
|
||||||
|
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||||
|
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
|
||||||
|
<SelectItem value="SENDING">Sending</SelectItem>
|
||||||
|
<SelectItem value="SENT">Sent</SelectItem>
|
||||||
|
<SelectItem value="CANCELLED">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campaigns List */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="mt-2 text-sm text-neutral-500">Loading campaigns...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && data?.campaigns.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Mail className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-neutral-900 mb-2">No campaigns found</h3>
|
||||||
|
<p className="text-neutral-500">
|
||||||
|
{statusFilter !== 'ALL' ? 'Try adjusting your filter' : 'Create your first campaign to get started'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.campaigns.map(campaign => (
|
||||||
|
<Card
|
||||||
|
key={campaign.id}
|
||||||
|
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all"
|
||||||
|
onClick={() => handleCampaignClick(campaign)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<CardTitle className="text-base truncate">{campaign.name}</CardTitle>
|
||||||
|
{getStatusBadge(campaign.status)}
|
||||||
|
</div>
|
||||||
|
{campaign.description && (
|
||||||
|
<CardDescription className="text-xs line-clamp-1">{campaign.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="h-3.5 w-3.5 text-neutral-500" />
|
||||||
|
<span className="text-neutral-500">Subject:</span>
|
||||||
|
<span className="font-medium text-neutral-900 truncate">{campaign.subject}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-3.5 w-3.5 text-neutral-500" />
|
||||||
|
<span className="text-neutral-500">Audience:</span>
|
||||||
|
<span className="text-neutral-700">{getAudienceLabel(campaign)}</span>
|
||||||
|
</div>
|
||||||
|
{campaign.scheduledFor && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-3.5 w-3.5 text-neutral-500" />
|
||||||
|
<span className="text-neutral-500">Scheduled:</span>
|
||||||
|
<span className="text-neutral-700">
|
||||||
|
{new Date(campaign.scheduledFor).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data && data.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t">
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Page {page} of {data.totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={page === data.totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6 pr-2">
|
||||||
|
{/* Campaign Preview */}
|
||||||
|
{selectedCampaign && (
|
||||||
|
<Card className="bg-neutral-50">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<CardTitle className="text-base">{selectedCampaign.name}</CardTitle>
|
||||||
|
{getStatusBadge(selectedCampaign.status)}
|
||||||
|
</div>
|
||||||
|
{selectedCampaign.description && (
|
||||||
|
<CardDescription className="text-xs">{selectedCampaign.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Field Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('subject')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="subject"
|
||||||
|
checked={selectedFields.subject}
|
||||||
|
onCheckedChange={() => toggleField('subject')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="subject" className="text-sm font-medium cursor-pointer">
|
||||||
|
Email Subject
|
||||||
|
</Label>
|
||||||
|
{selectedCampaign?.subject && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.subject}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('body')}
|
||||||
|
>
|
||||||
|
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="body" className="text-sm font-medium cursor-pointer">
|
||||||
|
Email Body
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">The full email content and design</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('from')}
|
||||||
|
>
|
||||||
|
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="from" className="text-sm font-medium cursor-pointer">
|
||||||
|
From Email
|
||||||
|
</Label>
|
||||||
|
{selectedCampaign?.from && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.from}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('fromName')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="fromName"
|
||||||
|
checked={selectedFields.fromName}
|
||||||
|
onCheckedChange={() => toggleField('fromName')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="fromName" className="text-sm font-medium cursor-pointer">
|
||||||
|
From Name
|
||||||
|
</Label>
|
||||||
|
{selectedCampaign?.fromName && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.fromName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('replyTo')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="replyTo"
|
||||||
|
checked={selectedFields.replyTo}
|
||||||
|
onCheckedChange={() => toggleField('replyTo')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="replyTo" className="text-sm font-medium cursor-pointer">
|
||||||
|
Reply-To Email
|
||||||
|
</Label>
|
||||||
|
{selectedCampaign?.replyTo && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.replyTo}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('audience')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="audience"
|
||||||
|
checked={selectedFields.audience}
|
||||||
|
onCheckedChange={() => toggleField('audience')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="audience" className="text-sm font-medium cursor-pointer">
|
||||||
|
Audience Settings
|
||||||
|
</Label>
|
||||||
|
{selectedCampaign && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{getAudienceLabel(selectedCampaign)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fixed Action Buttons */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t mt-4">
|
||||||
|
<Button variant="outline" onClick={handleBack} className="flex-1">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!Object.values(selectedFields).some(v => v)}
|
||||||
|
>
|
||||||
|
Create Campaign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -192,7 +192,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
|
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
|
||||||
{project.name.charAt(0).toUpperCase()}
|
{project.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-neutral-900">{project.name}</span>
|
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
|
||||||
{activeProject?.id === project.id && (
|
{activeProject?.id === project.id && (
|
||||||
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
|
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import {TextAlign} from '@tiptap/extension-text-align';
|
|||||||
import {Color} from '@tiptap/extension-color';
|
import {Color} from '@tiptap/extension-color';
|
||||||
import {TextStyle} from '@tiptap/extension-text-style';
|
import {TextStyle} from '@tiptap/extension-text-style';
|
||||||
import {Link} from '@tiptap/extension-link';
|
import {Link} from '@tiptap/extension-link';
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
import {Variable} from './VariableExtension';
|
import {Variable} from './VariableExtension';
|
||||||
import {setAvailableVariables, VariableMention} from './VariableMention';
|
import {setAvailableVariables, VariableMention} from './VariableMention';
|
||||||
import {Toolbar} from './Toolbar';
|
import {Toolbar} from './Toolbar';
|
||||||
import {ResizableImage} from './ResizableImage';
|
import {ResizableImage} from './ResizableImage';
|
||||||
|
import {HtmlEditor} from './HtmlEditor';
|
||||||
import {useContactFields, useContacts} from '../../lib/hooks/useContacts';
|
import {useContactFields, useContacts} from '../../lib/hooks/useContacts';
|
||||||
import {useConfig} from '../../lib/hooks/useConfig';
|
import {useConfig} from '../../lib/hooks/useConfig';
|
||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
@@ -25,7 +27,7 @@ import {
|
|||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
|
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
@@ -153,6 +155,9 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
|||||||
ResizableImage,
|
ResizableImage,
|
||||||
Variable,
|
Variable,
|
||||||
VariableMention,
|
VariableMention,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: placeholder || 'Your next email starts here!',
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
// Only initialize with content if starting in visual mode
|
// Only initialize with content if starting in visual mode
|
||||||
// If starting in HTML mode (due to custom HTML), keep editor empty
|
// If starting in HTML mode (due to custom HTML), keep editor empty
|
||||||
@@ -552,12 +557,11 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="min-h-[400px] max-h-[600px] overflow-y-auto">
|
<div className="min-h-[400px] max-h-[600px] overflow-hidden">
|
||||||
<textarea
|
<HtmlEditor
|
||||||
value={htmlContent}
|
value={htmlContent}
|
||||||
onChange={e => handleHtmlChange(e.target.value)}
|
onChange={handleHtmlChange}
|
||||||
placeholder={placeholder || '<h1>Welcome!</h1><p>Your email content here...</p>'}
|
placeholder={placeholder || 'Your next email starts here!'}
|
||||||
className="w-full h-full px-4 py-3 text-sm font-mono resize-none min-h-[400px] focus:outline-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedContactId && (
|
{selectedContactId && (
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import CodeMirror from '@uiw/react-codemirror';
|
||||||
|
import {html} from '@codemirror/lang-html';
|
||||||
|
import {EditorView} from '@codemirror/view';
|
||||||
|
import {useMemo} from 'react';
|
||||||
|
|
||||||
|
interface HtmlEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HtmlEditor({value, onChange, placeholder}: HtmlEditorProps) {
|
||||||
|
// Custom theme configuration for better integration with the app
|
||||||
|
const theme = EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
minHeight: '400px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
caretColor: '#1f2937',
|
||||||
|
},
|
||||||
|
'.cm-line': {
|
||||||
|
padding: '0',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
color: '#9ca3af',
|
||||||
|
border: 'none',
|
||||||
|
borderRight: '1px solid #e5e7eb',
|
||||||
|
paddingRight: '8px',
|
||||||
|
paddingLeft: '8px',
|
||||||
|
},
|
||||||
|
'.cm-activeLineGutter': {
|
||||||
|
backgroundColor: '#f3f4f6',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
'.cm-activeLine': {
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
},
|
||||||
|
'.cm-selectionBackground, ::selection': {
|
||||||
|
backgroundColor: '#dbeafe !important',
|
||||||
|
},
|
||||||
|
'.cm-focused .cm-selectionBackground, .cm-focused ::selection': {
|
||||||
|
backgroundColor: '#bfdbfe !important',
|
||||||
|
},
|
||||||
|
'.cm-cursor': {
|
||||||
|
borderLeftColor: '#1f2937',
|
||||||
|
},
|
||||||
|
'&.cm-focused': {
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
'.cm-scroller': {
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '600px',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extensions with HTML support and line numbers
|
||||||
|
const extensions = useMemo(
|
||||||
|
() => [
|
||||||
|
html(),
|
||||||
|
theme,
|
||||||
|
EditorView.lineWrapping, // Enable line wrapping for long lines
|
||||||
|
],
|
||||||
|
[theme]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="html-editor-container">
|
||||||
|
<CodeMirror
|
||||||
|
value={value}
|
||||||
|
height="100%"
|
||||||
|
extensions={extensions}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
basicSetup={{
|
||||||
|
lineNumbers: true,
|
||||||
|
highlightActiveLineGutter: true,
|
||||||
|
highlightActiveLine: true,
|
||||||
|
foldGutter: true,
|
||||||
|
dropCursor: true,
|
||||||
|
allowMultipleSelections: true,
|
||||||
|
indentOnInput: true,
|
||||||
|
bracketMatching: true,
|
||||||
|
closeBrackets: true,
|
||||||
|
autocompletion: true,
|
||||||
|
rectangularSelection: true,
|
||||||
|
crosshairCursor: true,
|
||||||
|
highlightSelectionMatches: true,
|
||||||
|
closeBracketsKeymap: true,
|
||||||
|
searchKeymap: true,
|
||||||
|
foldKeymap: true,
|
||||||
|
completionKeymap: true,
|
||||||
|
lintKeymap: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -213,7 +213,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-neutral-200 bg-neutral-50 p-2 flex flex-wrap gap-1 sticky top-0 z-10">
|
<div className="border-b border-neutral-200 bg-neutral-50 p-2 flex flex-wrap gap-1 sticky top-0 z-40">
|
||||||
{/* History */}
|
{/* History */}
|
||||||
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
|
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
|
||||||
<Button
|
<Button
|
||||||
@@ -424,7 +424,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
|
|||||||
</Button>
|
</Button>
|
||||||
{showColorPicker && (
|
{showColorPicker && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-20 max-h-96 overflow-y-auto"
|
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-50 max-h-96 overflow-y-auto"
|
||||||
style={{width: '280px'}}
|
style={{width: '280px'}}
|
||||||
>
|
>
|
||||||
{/* Custom color input */}
|
{/* Custom color input */}
|
||||||
@@ -508,7 +508,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
|
|||||||
<Link className="h-4 w-4" />
|
<Link className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{showLinkInput && (
|
{showLinkInput && (
|
||||||
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-20 min-w-max">
|
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-50 min-w-max">
|
||||||
<div className="flex gap-2 mb-2">
|
<div className="flex gap-2 mb-2">
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
|
|||||||
@@ -28,10 +28,15 @@ export function EmailSettings({
|
|||||||
onReplyToChange,
|
onReplyToChange,
|
||||||
fromPlaceholder = 'hello',
|
fromPlaceholder = 'hello',
|
||||||
fromNamePlaceholder = 'Your Company',
|
fromNamePlaceholder = 'Your Company',
|
||||||
replyToPlaceholder = 'support',
|
replyToPlaceholder,
|
||||||
showFromNameHelpText = false,
|
showFromNameHelpText = false,
|
||||||
layout = 'grid',
|
layout = 'grid',
|
||||||
}: EmailSettingsProps) {
|
}: EmailSettingsProps) {
|
||||||
|
// Use from email's local part as the reply-to placeholder if not provided
|
||||||
|
// Extract local part (before @) since the domain is shown in a dropdown
|
||||||
|
// Fall back to fromPlaceholder when from is empty (since reply-to defaults to from)
|
||||||
|
const fromLocalPart = from.includes('@') ? from.split('@')[0] : from;
|
||||||
|
const effectiveReplyToPlaceholder = replyToPlaceholder || fromLocalPart || fromPlaceholder;
|
||||||
const GridWrapper = layout === 'grid' ? 'div' : 'div';
|
const GridWrapper = layout === 'grid' ? 'div' : 'div';
|
||||||
const gridClassName = layout === 'grid' ? 'grid gap-4 md:grid-cols-2' : 'space-y-4';
|
const gridClassName = layout === 'grid' ? 'grid gap-4 md:grid-cols-2' : 'space-y-4';
|
||||||
|
|
||||||
@@ -73,7 +78,7 @@ export function EmailSettings({
|
|||||||
label="Reply-To Email"
|
label="Reply-To Email"
|
||||||
value={replyTo}
|
value={replyTo}
|
||||||
onChange={onReplyToChange}
|
onChange={onReplyToChange}
|
||||||
placeholder={replyToPlaceholder}
|
placeholder={effectiveReplyToPlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</GridWrapper>
|
</GridWrapper>
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Progress,
|
||||||
|
} from '@plunk/ui';
|
||||||
|
import {AlertCircle, AlertTriangle, CheckCircle, Shield} from 'lucide-react';
|
||||||
|
import type {ProjectSecurityMetrics} from '@plunk/types';
|
||||||
|
|
||||||
|
interface SecuritySettingsProps {
|
||||||
|
metrics: ProjectSecurityMetrics;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Security Overview</CardTitle>
|
||||||
|
<CardDescription>Monitor your project's email health and reputation</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-neutral-500">Loading security metrics...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {status, thresholds, isDisabled} = metrics;
|
||||||
|
|
||||||
|
// Helper to get status color and icon
|
||||||
|
const getStatusIndicator = (rate: number, warningThreshold: number, criticalThreshold: number) => {
|
||||||
|
if (rate >= criticalThreshold) {
|
||||||
|
return {color: 'text-red-600', icon: AlertCircle, bg: 'bg-red-600', label: 'Critical'};
|
||||||
|
}
|
||||||
|
if (rate >= warningThreshold) {
|
||||||
|
return {color: 'text-orange-600', icon: AlertTriangle, bg: 'bg-orange-500', label: 'Warning'};
|
||||||
|
}
|
||||||
|
return {color: 'text-green-600', icon: CheckCircle, bg: 'bg-green-600', label: 'Healthy'};
|
||||||
|
};
|
||||||
|
|
||||||
|
const sevenDayBounceStatus = getStatusIndicator(
|
||||||
|
status.sevenDay.bounceRate,
|
||||||
|
thresholds.BOUNCE_7DAY_WARNING,
|
||||||
|
thresholds.BOUNCE_7DAY_CRITICAL,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allTimeBounceStatus = getStatusIndicator(
|
||||||
|
status.allTime.bounceRate,
|
||||||
|
thresholds.BOUNCE_ALLTIME_WARNING,
|
||||||
|
thresholds.BOUNCE_ALLTIME_CRITICAL,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sevenDayComplaintStatus = getStatusIndicator(
|
||||||
|
status.sevenDay.complaintRate,
|
||||||
|
thresholds.COMPLAINT_7DAY_WARNING,
|
||||||
|
thresholds.COMPLAINT_7DAY_CRITICAL,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allTimeComplaintStatus = getStatusIndicator(
|
||||||
|
status.allTime.complaintRate,
|
||||||
|
thresholds.COMPLAINT_ALLTIME_WARNING,
|
||||||
|
thresholds.COMPLAINT_ALLTIME_CRITICAL,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Overall Status Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${status.isHealthy ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||||
|
<Shield className={`h-5 w-5 ${status.isHealthy ? 'text-green-600' : 'text-red-600'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Security Overview</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{status.isHealthy ? 'Your project is in good standing' : 'Action required to maintain project health'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Project Disabled Alert */}
|
||||||
|
{isDisabled && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Project Disabled</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This project has been disabled due to critical security violations. Contact support to resolve.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Violations */}
|
||||||
|
{status.violations.length > 0 && !isDisabled && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Critical Violations ({status.violations.length})</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm mt-2">
|
||||||
|
{status.violations.map((violation, idx) => (
|
||||||
|
<li key={idx}>{violation}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{status.warnings.length > 0 && (
|
||||||
|
<Alert variant="warning" className="mb-4">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Security Warnings ({status.warnings.length})</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm mt-2">
|
||||||
|
{status.warnings.map((warning, idx) => (
|
||||||
|
<li key={idx}>{warning}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Healthy Status */}
|
||||||
|
{status.isHealthy && !isDisabled && (
|
||||||
|
<Alert>
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertDescription>
|
||||||
|
All security metrics are within acceptable thresholds. Keep up the good work!
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bounce Rate Metrics */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bounce Rate Metrics</CardTitle>
|
||||||
|
<CardDescription>Hard bounces indicate invalid or non-existent email addresses</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 7-Day Bounce Rate */}
|
||||||
|
<MetricDisplay
|
||||||
|
label="7-Day Bounce Rate"
|
||||||
|
rate={status.sevenDay.bounceRate}
|
||||||
|
count={status.sevenDay.bounces}
|
||||||
|
total={status.sevenDay.total}
|
||||||
|
warningThreshold={thresholds.BOUNCE_7DAY_WARNING}
|
||||||
|
criticalThreshold={thresholds.BOUNCE_7DAY_CRITICAL}
|
||||||
|
status={sevenDayBounceStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* All-Time Bounce Rate */}
|
||||||
|
<MetricDisplay
|
||||||
|
label="All-Time Bounce Rate"
|
||||||
|
rate={status.allTime.bounceRate}
|
||||||
|
count={status.allTime.bounces}
|
||||||
|
total={status.allTime.total}
|
||||||
|
warningThreshold={thresholds.BOUNCE_ALLTIME_WARNING}
|
||||||
|
criticalThreshold={thresholds.BOUNCE_ALLTIME_CRITICAL}
|
||||||
|
status={allTimeBounceStatus}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Complaint Rate Metrics */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Complaint Rate Metrics</CardTitle>
|
||||||
|
<CardDescription>Complaints occur when recipients mark emails as spam</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 7-Day Complaint Rate */}
|
||||||
|
<MetricDisplay
|
||||||
|
label="7-Day Complaint Rate"
|
||||||
|
rate={status.sevenDay.complaintRate}
|
||||||
|
count={status.sevenDay.complaints}
|
||||||
|
total={status.sevenDay.total}
|
||||||
|
warningThreshold={thresholds.COMPLAINT_7DAY_WARNING}
|
||||||
|
criticalThreshold={thresholds.COMPLAINT_7DAY_CRITICAL}
|
||||||
|
status={sevenDayComplaintStatus}
|
||||||
|
isComplaintRate
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* All-Time Complaint Rate */}
|
||||||
|
<MetricDisplay
|
||||||
|
label="All-Time Complaint Rate"
|
||||||
|
rate={status.allTime.complaintRate}
|
||||||
|
count={status.allTime.complaints}
|
||||||
|
total={status.allTime.total}
|
||||||
|
warningThreshold={thresholds.COMPLAINT_ALLTIME_WARNING}
|
||||||
|
criticalThreshold={thresholds.COMPLAINT_ALLTIME_CRITICAL}
|
||||||
|
status={allTimeComplaintStatus}
|
||||||
|
isComplaintRate
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricDisplayProps {
|
||||||
|
label: string;
|
||||||
|
rate: number;
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
warningThreshold: number;
|
||||||
|
criticalThreshold: number;
|
||||||
|
status: {
|
||||||
|
color: string;
|
||||||
|
icon: React.ComponentType<{className?: string}>;
|
||||||
|
bg: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
isComplaintRate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricDisplay({
|
||||||
|
label,
|
||||||
|
rate,
|
||||||
|
count,
|
||||||
|
total,
|
||||||
|
warningThreshold,
|
||||||
|
criticalThreshold,
|
||||||
|
status,
|
||||||
|
isComplaintRate = false,
|
||||||
|
}: MetricDisplayProps) {
|
||||||
|
const Icon = status.icon;
|
||||||
|
const progressValue = Math.min((rate / criticalThreshold) * 100, 100);
|
||||||
|
const decimals = isComplaintRate ? 3 : 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-neutral-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-neutral-900">{label}</h3>
|
||||||
|
<p className="text-sm text-neutral-600 mt-1">
|
||||||
|
{count.toLocaleString()} / {total.toLocaleString()} emails
|
||||||
|
<span className="text-neutral-400 mx-2">•</span>
|
||||||
|
<strong>{rate.toFixed(decimals)}%</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-2 ${status.color}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">{status.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Progress value={progressValue} className="h-2" indicatorClassName={status.bg} />
|
||||||
|
<div className="flex justify-between text-xs text-neutral-500">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>Warning: {warningThreshold}%</span>
|
||||||
|
<span>Critical: {criticalThreshold}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import {Alert, AlertDescription, AlertTitle, Button} from '@plunk/ui';
|
||||||
|
import {AlertTriangle} from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type {SecurityStatus} from '@plunk/types';
|
||||||
|
|
||||||
|
interface SecurityWarningBannerProps {
|
||||||
|
status: SecurityStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
|
||||||
|
// Don't show if no warnings or already disabled
|
||||||
|
if (status.warnings.length === 0 || status.shouldDisable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Security Warning - Action Required</AlertTitle>
|
||||||
|
<AlertDescription className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Your project has exceeded the following security thresholds:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm text-amber-800">
|
||||||
|
{status.warnings.map((warning, idx) => (
|
||||||
|
<li key={idx}>{warning}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-amber-800 mt-2">
|
||||||
|
High bounce or complaint rates can lead to project suspension. Review the detailed metrics
|
||||||
|
and take action to improve your email quality.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/settings?tab=security">
|
||||||
|
<Button size="sm" variant="outline" className="w-full sm:w-auto flex-shrink-0">
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
Label
|
||||||
|
} from '@plunk/ui';
|
||||||
|
import type {Template} from '@plunk/db';
|
||||||
|
import {ArrowLeft, FileText, Search} from 'lucide-react';
|
||||||
|
import {useState} from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
interface PaginatedTemplates {
|
||||||
|
templates: Template[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateSelectionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSelectTemplate: (template: Template, selectedFields: SelectedFields) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedFields {
|
||||||
|
subject: boolean;
|
||||||
|
body: boolean;
|
||||||
|
from: boolean;
|
||||||
|
fromName: boolean;
|
||||||
|
replyTo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}: TemplateSelectionDialogProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [typeFilter, setTypeFilter] = useState<'ALL' | 'TRANSACTIONAL' | 'MARKETING'>('ALL');
|
||||||
|
const [step, setStep] = useState<'select' | 'configure'>('select');
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||||
|
const [selectedFields, setSelectedFields] = useState<SelectedFields>({
|
||||||
|
subject: true,
|
||||||
|
body: true,
|
||||||
|
from: true,
|
||||||
|
fromName: true,
|
||||||
|
replyTo: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {data, isLoading} = useSWR<PaginatedTemplates>(
|
||||||
|
open
|
||||||
|
? `/templates?page=${page}&pageSize=10${search ? `&search=${search}` : ''}${typeFilter !== 'ALL' ? `&type=${typeFilter}` : ''}`
|
||||||
|
: null,
|
||||||
|
{revalidateOnFocus: false},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearch(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemplateClick = (template: Template) => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setStep('configure');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep('select');
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedTemplate) {
|
||||||
|
onSelectTemplate(selectedTemplate, selectedFields);
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset state
|
||||||
|
setSearch('');
|
||||||
|
setSearchInput('');
|
||||||
|
setPage(1);
|
||||||
|
setTypeFilter('ALL');
|
||||||
|
setStep('select');
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
setSelectedFields({
|
||||||
|
subject: true,
|
||||||
|
body: true,
|
||||||
|
from: true,
|
||||||
|
fromName: true,
|
||||||
|
replyTo: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleField = (field: keyof SelectedFields) => {
|
||||||
|
setSelectedFields(prev => ({...prev, [field]: !prev[field]}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{step === 'configure' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleBack} className="h-8 w-8 p-0">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 'select' ? 'Select a Template' : 'Choose What to Copy'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{step === 'select'
|
||||||
|
? 'Choose a template to start your campaign with'
|
||||||
|
: 'Select which parts of the template you want to use'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 'select' ? (
|
||||||
|
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search templates..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
{search && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSearch('');
|
||||||
|
setSearchInput('');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Type Filter */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTypeFilter('ALL');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
variant={typeFilter === 'ALL' ? 'default' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
All Templates
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTypeFilter('MARKETING');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
variant={typeFilter === 'MARKETING' ? 'default' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Marketing
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTypeFilter('TRANSACTIONAL');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
variant={typeFilter === 'TRANSACTIONAL' ? 'default' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Transactional
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Templates List */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="mt-2 text-sm text-neutral-500">Loading templates...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && data?.templates.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<FileText className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-neutral-900 mb-2">No templates found</h3>
|
||||||
|
<p className="text-neutral-500">
|
||||||
|
{search ? 'Try adjusting your search terms' : 'Create a template first to use this feature'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.templates.map(template => (
|
||||||
|
<Card
|
||||||
|
key={template.id}
|
||||||
|
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all"
|
||||||
|
onClick={() => handleTemplateClick(template)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<CardTitle className="text-base truncate">{template.name}</CardTitle>
|
||||||
|
<Badge className="capitalize" variant={template.type === 'MARKETING' ? 'info' : 'success'}>
|
||||||
|
{template.type.toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{template.description && (
|
||||||
|
<CardDescription className="text-xs line-clamp-1">{template.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-neutral-500">Subject:</span>{' '}
|
||||||
|
<span className="font-medium text-neutral-900">{template.subject}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-neutral-500">From:</span>{' '}
|
||||||
|
<span className="text-neutral-700">{template.from}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data && data.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t">
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Page {page} of {data.totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={page === data.totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6 pr-2">
|
||||||
|
{/* Template Preview */}
|
||||||
|
{selectedTemplate && (
|
||||||
|
<Card className="bg-neutral-50">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<CardTitle className="text-base">{selectedTemplate.name}</CardTitle>
|
||||||
|
<Badge
|
||||||
|
className="capitalize"
|
||||||
|
variant={selectedTemplate.type === 'MARKETING' ? 'info' : 'success'}
|
||||||
|
>
|
||||||
|
{selectedTemplate.type.toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{selectedTemplate.description && (
|
||||||
|
<CardDescription className="text-xs">{selectedTemplate.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Field Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('subject')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="subject"
|
||||||
|
checked={selectedFields.subject}
|
||||||
|
onCheckedChange={() => toggleField('subject')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="subject" className="text-sm font-medium cursor-pointer">
|
||||||
|
Email Subject
|
||||||
|
</Label>
|
||||||
|
{selectedTemplate?.subject && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.subject}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('body')}
|
||||||
|
>
|
||||||
|
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="body" className="text-sm font-medium cursor-pointer">
|
||||||
|
Email Body
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">The full email content and design</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('from')}
|
||||||
|
>
|
||||||
|
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="from" className="text-sm font-medium cursor-pointer">
|
||||||
|
From Email
|
||||||
|
</Label>
|
||||||
|
{selectedTemplate?.from && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.from}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('fromName')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="fromName"
|
||||||
|
checked={selectedFields.fromName}
|
||||||
|
onCheckedChange={() => toggleField('fromName')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="fromName" className="text-sm font-medium cursor-pointer">
|
||||||
|
From Name
|
||||||
|
</Label>
|
||||||
|
{selectedTemplate?.fromName && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.fromName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('replyTo')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="replyTo"
|
||||||
|
checked={selectedFields.replyTo}
|
||||||
|
onCheckedChange={() => toggleField('replyTo')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="replyTo" className="text-sm font-medium cursor-pointer">
|
||||||
|
Reply-To Email
|
||||||
|
</Label>
|
||||||
|
{selectedTemplate?.replyTo && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.replyTo}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fixed Action Buttons */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t mt-4">
|
||||||
|
<Button variant="outline" onClick={handleBack} className="flex-1">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm} className="flex-1" disabled={!Object.values(selectedFields).some(v => v)}>
|
||||||
|
Create Campaign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* Shared date formatting and manipulation utilities
|
* Shared date formatting and manipulation utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the user's timezone
|
* Get the user's timezone
|
||||||
*/
|
*/
|
||||||
@@ -106,3 +108,21 @@ export function formatUTCDateTime(date: Date): string {
|
|||||||
timeZone: 'UTC',
|
timeZone: 'UTC',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date as relative time with short format for very recent updates
|
||||||
|
* Returns "just now" for updates within the last minute to avoid text wrapping
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(date: Date | string): string {
|
||||||
|
const now = dayjs();
|
||||||
|
const then = dayjs(date);
|
||||||
|
const secondsAgo = now.diff(then, 'second');
|
||||||
|
|
||||||
|
// If less than 60 seconds, show "just now"
|
||||||
|
if (secondsAgo < 60) {
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use standard relative time
|
||||||
|
return then.fromNow();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import useSWR from 'swr';
|
||||||
|
import type {ProjectSecurityMetrics} from '@plunk/types';
|
||||||
|
|
||||||
|
export interface ProjectSecurityResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: ProjectSecurityMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch project security metrics
|
||||||
|
* Auto-refreshes every 2 minutes to keep data current
|
||||||
|
*/
|
||||||
|
export function useProjectSecurity(projectId: string | undefined) {
|
||||||
|
const {data, error, isLoading, mutate} = useSWR<ProjectSecurityResponse>(
|
||||||
|
projectId ? `/projects/${projectId}/security` : null,
|
||||||
|
{
|
||||||
|
refreshInterval: 120000, // 2 minutes
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
securityMetrics: data?.data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,9 +10,16 @@ import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
|
|||||||
import {useProjects} from '../lib/hooks/useProject';
|
import {useProjects} from '../lib/hooks/useProject';
|
||||||
import {useUser} from '../lib/hooks/useUser';
|
import {useUser} from '../lib/hooks/useUser';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||||
|
|
||||||
|
// Configure dayjs plugins globally
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.extend(advancedFormat);
|
||||||
|
|
||||||
// Routes that don't require authentication
|
// Routes that don't require authentication
|
||||||
const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset'];
|
const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset', '/unsubscribe', '/subscribe', '/manage'];
|
||||||
|
|
||||||
// Routes that don't require a project
|
// Routes that don't require a project
|
||||||
const NO_PROJECT_ROUTES = ['/projects/create'];
|
const NO_PROJECT_ROUTES = ['/projects/create'];
|
||||||
@@ -28,7 +35,9 @@ function App({Component, pageProps}: AppProps) {
|
|||||||
function AuthGuard({children}: {children: React.ReactNode}) {
|
function AuthGuard({children}: {children: React.ReactNode}) {
|
||||||
const {data: user, isLoading} = useUser();
|
const {data: user, isLoading} = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isPublicRoute = PUBLIC_ROUTES.includes(router.pathname);
|
const isPublicRoute = PUBLIC_ROUTES.some(route =>
|
||||||
|
router.pathname === route || router.pathname.startsWith(`${route}/`)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If not loading, no user, and trying to access a protected route, redirect to login
|
// If not loading, no user, and trying to access a protected route, redirect to login
|
||||||
@@ -102,7 +111,9 @@ export default function WithProviders(props: AppProps) {
|
|||||||
|
|
||||||
function Root(props: AppProps) {
|
function Root(props: AppProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isPublicRoute = PUBLIC_ROUTES.includes(router.pathname);
|
const isPublicRoute = PUBLIC_ROUTES.some(route =>
|
||||||
|
router.pathname === route || router.pathname.startsWith(`${route}/`)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Calendar,
|
Calendar,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Info,
|
||||||
Mail,
|
Mail,
|
||||||
MousePointer,
|
MousePointer,
|
||||||
Save,
|
Save,
|
||||||
@@ -86,7 +87,7 @@ export default function CampaignDetailsPage() {
|
|||||||
id && campaign?.data.status !== CampaignStatus.DRAFT ? `/campaigns/${id}/stats` : null,
|
id && campaign?.data.status !== CampaignStatus.DRAFT ? `/campaigns/${id}/stats` : null,
|
||||||
{
|
{
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
refreshInterval: campaign?.data.status === CampaignStatus.SENDING ? 5000 : 0, // Refresh every 5s if sending
|
refreshInterval: campaign?.data.status === CampaignStatus.SENDING ? 15000 : 0, // Refresh every 15s while sending
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ export default function CampaignDetailsPage() {
|
|||||||
|
|
||||||
// Fetch project members for test email
|
// Fetch project members for test email
|
||||||
const {data: projectMembers} = useSWR<{data: Array<{userId: string; email: string; role: string}>}>(
|
const {data: projectMembers} = useSWR<{data: Array<{userId: string; email: string; role: string}>}>(
|
||||||
id ? `/projects/${campaign?.data.projectId}/members` : null,
|
id && campaign?.data.projectId ? `/projects/${campaign.data.projectId}/members` : null,
|
||||||
{revalidateOnFocus: false},
|
{revalidateOnFocus: false},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -213,14 +214,28 @@ export default function CampaignDetailsPage() {
|
|||||||
subject: editedCampaign.subject,
|
subject: editedCampaign.subject,
|
||||||
body: editedCampaign.body,
|
body: editedCampaign.body,
|
||||||
from: editedCampaign.from,
|
from: editedCampaign.from,
|
||||||
fromName: editedCampaign.fromName || undefined,
|
fromName: editedCampaign.fromName || null,
|
||||||
replyTo: editedCampaign.replyTo || undefined,
|
replyTo: editedCampaign.replyTo || null,
|
||||||
audienceType: editedCampaign.audienceType,
|
audienceType: editedCampaign.audienceType,
|
||||||
segmentId: editedCampaign.segmentId || undefined,
|
segmentId: editedCampaign.segmentId || undefined,
|
||||||
});
|
});
|
||||||
// Silent save - no toast notification
|
// Silent save - no toast notification
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
void mutate();
|
// Refetch and re-sync the edited campaign with fresh data
|
||||||
|
const updated = await mutate();
|
||||||
|
if (updated?.data) {
|
||||||
|
setEditedCampaign({
|
||||||
|
name: updated.data.name,
|
||||||
|
description: updated.data.description || '',
|
||||||
|
subject: updated.data.subject,
|
||||||
|
body: updated.data.body,
|
||||||
|
from: updated.data.from,
|
||||||
|
fromName: updated.data.fromName || '',
|
||||||
|
replyTo: updated.data.replyTo || '',
|
||||||
|
audienceType: updated.data.audienceType,
|
||||||
|
segmentId: updated.data.segmentId || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to update campaign');
|
toast.error(error instanceof Error ? error.message : 'Failed to update campaign');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -312,19 +327,9 @@ export default function CampaignDetailsPage() {
|
|||||||
const c = campaign.data;
|
const c = campaign.data;
|
||||||
const s = stats?.data;
|
const s = stats?.data;
|
||||||
|
|
||||||
// Get recipient count for draft campaigns
|
// Get recipient count for draft campaigns from the campaign's totalRecipients field
|
||||||
const getDraftRecipientCount = () => {
|
// The backend calculates this for all audience types when the campaign is created/updated
|
||||||
if (!campaign?.data) return 0;
|
const draftRecipientCount = isEditMode && campaign?.data ? campaign.data.totalRecipients : 0;
|
||||||
const c = campaign.data;
|
|
||||||
|
|
||||||
if (c.audienceType === CampaignAudienceType.SEGMENT && c.segmentId && segments) {
|
|
||||||
const segment = segments.find(s => s.id === c.segmentId);
|
|
||||||
return segment?.memberCount || 0;
|
|
||||||
}
|
|
||||||
return 0; // We'd need total contact count for ALL audience type
|
|
||||||
};
|
|
||||||
|
|
||||||
const draftRecipientCount = isEditMode ? getDraftRecipientCount() : 0;
|
|
||||||
|
|
||||||
// Render edit form for drafts
|
// Render edit form for drafts
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
@@ -386,18 +391,37 @@ export default function CampaignDetailsPage() {
|
|||||||
<ChevronDown className="h-4 w-4 sm:ml-1" />
|
<ChevronDown className="h-4 w-4 sm:ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-72">
|
||||||
<DropdownMenuItem onClick={() => setIsTestEmailDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setIsTestEmailDialogOpen(true)} className="py-3 cursor-pointer">
|
||||||
<TestTube className="h-4 w-4 mr-2" />
|
<div className="flex items-start gap-3">
|
||||||
Send Test Email
|
<TestTube className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">Send Test Email</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Preview in your inbox before sending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setShowSendDialog(true)}>
|
<DropdownMenuItem onClick={() => setShowSendDialog(true)} className="py-3 cursor-pointer">
|
||||||
<Send className="h-4 w-4 mr-2" />
|
<div className="flex items-start gap-3">
|
||||||
Send Now
|
<Send className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">Send Now</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Send immediately to all recipients
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setIsScheduleDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setIsScheduleDialogOpen(true)} className="py-3 cursor-pointer">
|
||||||
<Calendar className="h-4 w-4 mr-2" />
|
<div className="flex items-start gap-3">
|
||||||
Schedule for Later
|
<Calendar className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">Schedule for Later</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">Choose a specific date and time</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -497,11 +521,6 @@ export default function CampaignDetailsPage() {
|
|||||||
title="Segment"
|
title="Segment"
|
||||||
description="Target a defined group of contacts"
|
description="Target a defined group of contacts"
|
||||||
/>
|
/>
|
||||||
<SelectItemWithDescription
|
|
||||||
value={CampaignAudienceType.FILTERED}
|
|
||||||
title="Filtered"
|
|
||||||
description="Use advanced filter conditions"
|
|
||||||
/>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,13 +570,20 @@ export default function CampaignDetailsPage() {
|
|||||||
|
|
||||||
{/* Show recipient count */}
|
{/* Show recipient count */}
|
||||||
{draftRecipientCount > 0 && (
|
{draftRecipientCount > 0 && (
|
||||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="h-4 w-4 text-blue-600" />
|
<Users className="h-4 w-4 text-blue-600" />
|
||||||
<span className="text-sm font-medium text-blue-900">
|
<span className="text-sm font-medium text-blue-900">
|
||||||
{draftRecipientCount.toLocaleString()} recipients
|
{draftRecipientCount.toLocaleString()} recipients
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-blue-800">
|
||||||
|
This count will be recalculated right before sending to ensure accuracy. The final number may
|
||||||
|
differ if contacts subscribe, unsubscribe, or segment membership changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -565,7 +591,7 @@ export default function CampaignDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Editor - Full Width */}
|
{/* Email Editor - Full Width */}
|
||||||
<Card>
|
<Card className="overflow-visible">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Email Content</CardTitle>
|
<CardTitle>Email Content</CardTitle>
|
||||||
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
|
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
|
||||||
@@ -577,7 +603,6 @@ export default function CampaignDetailsPage() {
|
|||||||
setEditedCampaign({...editedCampaign, body});
|
setEditedCampaign({...editedCampaign, body});
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
}}
|
}}
|
||||||
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -972,6 +997,7 @@ export default function CampaignDetailsPage() {
|
|||||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Scheduled For</p>
|
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Scheduled For</p>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Calendar className="h-4 w-4 text-neutral-400 mt-0.5" />
|
<Calendar className="h-4 w-4 text-neutral-400 mt-0.5" />
|
||||||
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-900">
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
{formatFullDateTime(new Date(c.scheduledFor))}
|
{formatFullDateTime(new Date(c.scheduledFor))}
|
||||||
@@ -980,6 +1006,13 @@ export default function CampaignDetailsPage() {
|
|||||||
UTC: {formatUTCDateTime(new Date(c.scheduledFor))}
|
UTC: {formatUTCDateTime(new Date(c.scheduledFor))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{c.status === CampaignStatus.SCHEDULED && (
|
||||||
|
<div className="flex items-start gap-1.5 p-2 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<Info className="h-3 w-3 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-blue-800">Recipient count will be recalculated at send time</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -987,21 +1020,15 @@ export default function CampaignDetailsPage() {
|
|||||||
{/* Sent At */}
|
{/* Sent At */}
|
||||||
{c.sentAt && (
|
{c.sentAt && (
|
||||||
<div className="pb-3 border-b border-neutral-100">
|
<div className="pb-3 border-b border-neutral-100">
|
||||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent At</p>
|
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent On</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Send className="h-4 w-4 text-neutral-400" />
|
<Send className="h-4 w-4 text-neutral-400" />
|
||||||
<p className="text-sm font-medium text-neutral-900">
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
{new Date(c.sentAt).toLocaleDateString()} at {new Date(c.sentAt).toLocaleTimeString()}
|
{formatFullDateTime(new Date(c.sentAt))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Created */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Created</p>
|
|
||||||
<p className="text-sm text-neutral-900">{new Date(c.createdAt).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {EmailFormValidator} from '../../lib/validation';
|
|||||||
import {ArrowLeft, Save, Users} from 'lucide-react';
|
import {ArrowLeft, Save, Users} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||||
@@ -43,9 +43,92 @@ export default function CreateCampaignPage() {
|
|||||||
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
|
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
|
||||||
const [segmentId, setSegmentId] = useState('');
|
const [segmentId, setSegmentId] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [loadingTemplate, setLoadingTemplate] = useState(false);
|
||||||
|
|
||||||
const {data: segments} = useSWR<Segment[]>('/segments', {revalidateOnFocus: false});
|
const {data: segments} = useSWR<Segment[]>('/segments', {revalidateOnFocus: false});
|
||||||
|
|
||||||
|
// Load template or campaign data if provided in query params
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
const {
|
||||||
|
templateId,
|
||||||
|
campaignId,
|
||||||
|
name: queryName,
|
||||||
|
subject: querySubject,
|
||||||
|
from: queryFrom,
|
||||||
|
fromName: queryFromName,
|
||||||
|
replyTo: queryReplyTo,
|
||||||
|
audienceType: queryAudienceType,
|
||||||
|
segmentId: querySegmentId,
|
||||||
|
} = router.query;
|
||||||
|
|
||||||
|
// Handle template loading
|
||||||
|
if (templateId && typeof templateId === 'string') {
|
||||||
|
setLoadingTemplate(true);
|
||||||
|
try {
|
||||||
|
// Fetch the full template to get the body content
|
||||||
|
const template = await network.fetch<{data: {body: string}}>('GET', `/templates/${templateId}`);
|
||||||
|
|
||||||
|
// Pre-fill form with template data
|
||||||
|
if (queryName && typeof queryName === 'string') setName(queryName);
|
||||||
|
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
|
||||||
|
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
|
||||||
|
if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
|
||||||
|
if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
|
||||||
|
setBody(template.data.body);
|
||||||
|
|
||||||
|
toast.success('Template loaded successfully');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load template');
|
||||||
|
} finally {
|
||||||
|
setLoadingTemplate(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle campaign loading
|
||||||
|
else if (campaignId && typeof campaignId === 'string') {
|
||||||
|
setLoadingTemplate(true);
|
||||||
|
try {
|
||||||
|
// Fetch the full campaign to get the body content
|
||||||
|
const campaign = await network.fetch<{data: {body: string}}>('GET', `/campaigns/${campaignId}`);
|
||||||
|
|
||||||
|
// Pre-fill form with campaign data
|
||||||
|
if (queryName && typeof queryName === 'string') setName(queryName);
|
||||||
|
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
|
||||||
|
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
|
||||||
|
if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
|
||||||
|
if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
|
||||||
|
if (queryAudienceType && typeof queryAudienceType === 'string') {
|
||||||
|
setAudienceType(queryAudienceType as CampaignAudienceType);
|
||||||
|
}
|
||||||
|
if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId);
|
||||||
|
setBody(campaign.data.body);
|
||||||
|
|
||||||
|
toast.success('Campaign loaded successfully');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load campaign');
|
||||||
|
} finally {
|
||||||
|
setLoadingTemplate(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle query params without template/campaign ID (direct field values)
|
||||||
|
else {
|
||||||
|
if (queryName && typeof queryName === 'string') setName(queryName);
|
||||||
|
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
|
||||||
|
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
|
||||||
|
if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
|
||||||
|
if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
|
||||||
|
if (queryAudienceType && typeof queryAudienceType === 'string') {
|
||||||
|
setAudienceType(queryAudienceType as CampaignAudienceType);
|
||||||
|
}
|
||||||
|
if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (router.isReady) {
|
||||||
|
void loadData();
|
||||||
|
}
|
||||||
|
}, [router.isReady, router.query]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -64,8 +147,8 @@ export default function CreateCampaignPage() {
|
|||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
from,
|
from,
|
||||||
fromName: fromName || undefined,
|
fromName: fromName || null,
|
||||||
replyTo: replyTo || undefined,
|
replyTo: replyTo || null,
|
||||||
audienceType,
|
audienceType,
|
||||||
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
|
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
|
||||||
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
|
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
|
||||||
@@ -94,6 +177,26 @@ export default function CreateCampaignPage() {
|
|||||||
<>
|
<>
|
||||||
<NextSeo title="Create Campaign" />
|
<NextSeo title="Create Campaign" />
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
|
{loadingTemplate && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-lg p-6 text-center">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 animate-spin mx-auto text-neutral-900 mb-3"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-neutral-700">Loading template...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 sm:gap-4">
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
@@ -188,7 +291,7 @@ export default function CreateCampaignPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Email Content */}
|
{/* Email Content */}
|
||||||
<Card>
|
<Card className="overflow-visible">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" />
|
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -197,11 +300,7 @@ export default function CreateCampaignPage() {
|
|||||||
<Label htmlFor="body">
|
<Label htmlFor="body">
|
||||||
Email Body <span className="text-red-500">*</span>
|
Email Body <span className="text-red-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<EmailEditor
|
<EmailEditor value={body} onChange={setBody} />
|
||||||
value={body}
|
|
||||||
onChange={setBody}
|
|
||||||
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -283,6 +382,18 @@ export default function CreateCampaignPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{audienceType === CampaignAudienceType.ALL && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-900">All subscribed contacts</p>
|
||||||
|
<p className="text-xs text-blue-700 mt-1">
|
||||||
|
This campaign will be sent to all contacts who haven't unsubscribed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,6 +450,13 @@ export default function CreateCampaignPage() {
|
|||||||
<span className="font-medium">{estimatedRecipients.toLocaleString()}</span>
|
<span className="font-medium">{estimatedRecipients.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{audienceType === CampaignAudienceType.ALL && (
|
||||||
|
<div className="flex justify-between py-2">
|
||||||
|
<span className="text-neutral-500">Recipients</span>
|
||||||
|
<span className="font-medium">All subscribed</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Note */}
|
{/* Info Note */}
|
||||||
|
|||||||
@@ -7,22 +7,31 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import type {Campaign} from '@plunk/db';
|
import type {Campaign, Template} from '@plunk/db';
|
||||||
import {CampaignStatus} from '@plunk/db';
|
import {CampaignStatus} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
|
import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog';
|
||||||
|
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {Calendar, Copy, Mail, Plus, Trash2, Users} from 'lucide-react';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
|
import {Calendar, ChevronDown, Copy, FileText, Info, Mail, Plus, RefreshCw, Trash2, Users} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import {useRouter} from 'next/router';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface PaginatedCampaigns {
|
interface PaginatedCampaigns {
|
||||||
campaigns: Campaign[];
|
campaigns: Campaign[];
|
||||||
@@ -33,12 +42,15 @@ interface PaginatedCampaigns {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CampaignsPage() {
|
export default function CampaignsPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||||
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
|
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [campaignToDelete, setCampaignToDelete] = useState<string | null>(null);
|
const [campaignToDelete, setCampaignToDelete] = useState<string | null>(null);
|
||||||
|
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
|
||||||
|
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
|
||||||
|
|
||||||
const {data, mutate, isLoading} = useSWR<PaginatedCampaigns>(
|
const {data, mutate, isLoading} = useSWR<PaginatedCampaigns>(
|
||||||
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
|
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
|
||||||
@@ -103,6 +115,93 @@ export default function CampaignsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectTemplate = (
|
||||||
|
template: Template,
|
||||||
|
selectedFields: {
|
||||||
|
subject: boolean;
|
||||||
|
body: boolean;
|
||||||
|
from: boolean;
|
||||||
|
fromName: boolean;
|
||||||
|
replyTo: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
// Navigate to create page with template data as query params
|
||||||
|
const query: Record<string, string> = {
|
||||||
|
name: `${template.name}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include templateId if body is selected (needed to fetch body content)
|
||||||
|
if (selectedFields.body) {
|
||||||
|
query.templateId = template.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add selected fields to query params
|
||||||
|
if (selectedFields.subject) {
|
||||||
|
query.subject = template.subject;
|
||||||
|
}
|
||||||
|
if (selectedFields.from) {
|
||||||
|
query.from = template.from;
|
||||||
|
}
|
||||||
|
if (selectedFields.fromName && template.fromName) {
|
||||||
|
query.fromName = template.fromName;
|
||||||
|
}
|
||||||
|
if (selectedFields.replyTo && template.replyTo) {
|
||||||
|
query.replyTo = template.replyTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void router.push({
|
||||||
|
pathname: '/campaigns/create',
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectCampaign = (
|
||||||
|
campaign: Campaign,
|
||||||
|
selectedFields: {
|
||||||
|
subject: boolean;
|
||||||
|
body: boolean;
|
||||||
|
from: boolean;
|
||||||
|
fromName: boolean;
|
||||||
|
replyTo: boolean;
|
||||||
|
audience: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
// Navigate to create page with campaign data as query params
|
||||||
|
const query: Record<string, string> = {
|
||||||
|
name: `${campaign.name}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include campaignId if body is selected (needed to fetch body content)
|
||||||
|
if (selectedFields.body) {
|
||||||
|
query.campaignId = campaign.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add selected fields to query params
|
||||||
|
if (selectedFields.subject) {
|
||||||
|
query.subject = campaign.subject;
|
||||||
|
}
|
||||||
|
if (selectedFields.from) {
|
||||||
|
query.from = campaign.from;
|
||||||
|
}
|
||||||
|
if (selectedFields.fromName && campaign.fromName) {
|
||||||
|
query.fromName = campaign.fromName;
|
||||||
|
}
|
||||||
|
if (selectedFields.replyTo && campaign.replyTo) {
|
||||||
|
query.replyTo = campaign.replyTo;
|
||||||
|
}
|
||||||
|
if (selectedFields.audience) {
|
||||||
|
query.audienceType = campaign.audienceType;
|
||||||
|
if (campaign.segmentId) {
|
||||||
|
query.segmentId = campaign.segmentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void router.push({
|
||||||
|
pathname: '/campaigns/create',
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo title="Campaigns" />
|
<NextSeo title="Campaigns" />
|
||||||
@@ -116,13 +215,51 @@ export default function CampaignsPage() {
|
|||||||
Send one-time email broadcasts to your contacts. {data?.total ? `${data.total} total campaigns` : ''}
|
Send one-time email broadcasts to your contacts. {data?.total ? `${data.total} total campaigns` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/campaigns/create" className="w-full sm:w-auto">
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<Button className="w-full sm:w-auto">
|
<Button className="w-full sm:w-auto">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Create Campaign</span>
|
<span className="hidden sm:inline">Create Campaign</span>
|
||||||
<span className="sm:hidden">Create</span>
|
<span className="sm:hidden">Create</span>
|
||||||
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-80">
|
||||||
|
<DropdownMenuItem asChild className="py-3 cursor-pointer">
|
||||||
|
<Link href="/campaigns/create" className="flex items-start gap-3">
|
||||||
|
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">Empty Campaign</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Start from scratch with a blank canvas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">From Template</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Use an existing template as a starting point
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">From Previous Campaign</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Copy content and settings from an existing campaign
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -171,12 +308,60 @@ export default function CampaignsPage() {
|
|||||||
? 'Try adjusting your filters or create a new campaign.'
|
? 'Try adjusting your filters or create a new campaign.'
|
||||||
: 'Create your first campaign to send emails to your contacts.'}
|
: 'Create your first campaign to send emails to your contacts.'}
|
||||||
</p>
|
</p>
|
||||||
<Link href="/campaigns/create">
|
{statusFilter === 'ALL' && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<Button size="lg">
|
<Button size="lg">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Create Your First Campaign
|
Create Your First Campaign
|
||||||
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="w-80">
|
||||||
|
<DropdownMenuItem asChild className="py-3 cursor-pointer">
|
||||||
|
<Link href="/campaigns/create" className="flex items-start gap-3">
|
||||||
|
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">Empty Campaign</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Start from scratch with a blank canvas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">From Template</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Use an existing template as a starting point
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">From Previous Campaign</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Copy content and settings from an existing campaign
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
{statusFilter !== 'ALL' && (
|
||||||
|
<Link href="/campaigns/create">
|
||||||
|
<Button size="lg">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Campaign
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -216,11 +401,20 @@ export default function CampaignsPage() {
|
|||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Users className="h-3.5 w-3.5 text-blue-600" />
|
<Users className="h-3.5 w-3.5 text-blue-600" />
|
||||||
<span className="text-xs font-medium text-blue-900">Recipients</span>
|
<span className="text-xs font-medium text-blue-900">Recipients</span>
|
||||||
|
{(campaign.status === 'DRAFT' || campaign.status === 'SCHEDULED') && (
|
||||||
|
<div className="group relative">
|
||||||
|
<Info className="h-3 w-3 text-blue-600 cursor-help" />
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1">
|
||||||
|
This count will be recalculated before sending
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold text-blue-900">{campaign.totalRecipients.toLocaleString()}</p>
|
<p className="text-lg font-bold text-blue-900">{campaign.totalRecipients.toLocaleString()}</p>
|
||||||
{campaign.totalRecipients > 0 && (
|
{campaign.totalRecipients > 0 && campaign.status !== 'DRAFT' && (
|
||||||
<p className="text-xs text-blue-700 mt-1">{deliveryProgress.toFixed(0)}% sent</p>
|
<p className="text-xs text-blue-700 mt-1">{deliveryProgress.toFixed(0)}% sent</p>
|
||||||
)}
|
)}
|
||||||
|
{campaign.status === 'DRAFT' && <p className="text-xs text-blue-600 mt-1">Estimated</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Open Rate */}
|
{/* Open Rate */}
|
||||||
@@ -273,6 +467,25 @@ export default function CampaignsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>Created {formatRelativeTime(campaign.createdAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(campaign.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>• Updated {formatRelativeTime(campaign.updatedAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(campaign.updatedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 pt-2 border-t border-neutral-100">
|
<div className="flex gap-2 pt-2 border-t border-neutral-100">
|
||||||
<Link href={`/campaigns/${campaign.id}`} className="flex-1">
|
<Link href={`/campaigns/${campaign.id}`} className="flex-1">
|
||||||
@@ -356,6 +569,18 @@ export default function CampaignsPage() {
|
|||||||
confirmText="Delete Campaign"
|
confirmText="Delete Campaign"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TemplateSelectionDialog
|
||||||
|
open={showTemplateDialog}
|
||||||
|
onOpenChange={setShowTemplateDialog}
|
||||||
|
onSelectTemplate={handleSelectTemplate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CampaignSelectionDialog
|
||||||
|
open={showCampaignDialog}
|
||||||
|
onOpenChange={setShowCampaignDialog}
|
||||||
|
onSelectCampaign={handleSelectCampaign}
|
||||||
|
/>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,20 +7,21 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Input,
|
Input,
|
||||||
Label,
|
Label
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import type {Contact} from '@plunk/db';
|
import type {Contact} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
||||||
import {ActivityFeed} from '../../components/ActivityFeed';
|
import {ActivityFeed} from '../../components/ActivityFeed';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {ArrowLeft, Calendar, Copy, Database, ExternalLink, Mail, Save, Settings, Trash2} from 'lucide-react';
|
import {ArrowLeft, Copy, Database, ExternalLink, Mail, Save, Settings, Trash2} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {ContactSchemas} from '@plunk/shared';
|
import {ContactSchemas} from '@plunk/shared';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
export default function ContactDetailPage() {
|
export default function ContactDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -250,34 +251,6 @@ export default function ContactDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-neutral-900">Created</p>
|
|
||||||
<p className="text-sm text-neutral-500">
|
|
||||||
{new Date(contact.createdAt).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
|
|
||||||
<p className="text-sm text-neutral-500">
|
|
||||||
{new Date(contact.updatedAt).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
|
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -285,6 +258,26 @@ export default function ContactDetailPage() {
|
|||||||
<p className="text-xs text-neutral-500 font-mono break-all">{contact.id}</p>
|
<p className="text-xs text-neutral-500 font-mono break-all">{contact.id}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">Created</p>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<p className="text-sm text-neutral-500">{dayjs(contact.createdAt).fromNow()}</p>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<p className="text-sm text-neutral-500">{dayjs(contact.updatedAt).fromNow()}</p>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(contact.updatedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
Input,
|
Input,
|
||||||
Label,
|
Label,
|
||||||
Switch,
|
Switch
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import type {Contact} from '@plunk/db';
|
import type {Contact} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -32,7 +33,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
XCircle,
|
XCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -40,6 +41,7 @@ import {useEffect, useRef, useState} from 'react';
|
|||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {ContactSchemas} from '@plunk/shared';
|
import {ContactSchemas} from '@plunk/shared';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface PaginatedContacts {
|
interface PaginatedContacts {
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
@@ -279,7 +281,12 @@ export default function ContactsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
|
||||||
{new Date(contact.createdAt).toLocaleDateString()}
|
<div className="group relative inline-block cursor-help">
|
||||||
|
{formatRelativeTime(contact.createdAt)}
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
@@ -324,9 +331,12 @@ export default function ContactsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-neutral-500">
|
<div className="group relative inline-block cursor-help">
|
||||||
{new Date(contact.createdAt).toLocaleDateString()}
|
<span className="text-xs text-neutral-500">{formatRelativeTime(contact.createdAt)}</span>
|
||||||
</span>
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(contact.createdAt).format('Do MMMM YYYY, h:mm A')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link href={`/contacts/${contact.id}`}>
|
<Link href={`/contacts/${contact.id}`}>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
|
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
@@ -15,14 +15,19 @@ import Link from 'next/link';
|
|||||||
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
|
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
|
||||||
import {DashboardLayout} from '../components/DashboardLayout';
|
import {DashboardLayout} from '../components/DashboardLayout';
|
||||||
import {QuickStart} from '../components/QuickStart';
|
import {QuickStart} from '../components/QuickStart';
|
||||||
|
import {SecurityWarningBanner} from '../components/SecurityWarningBanner';
|
||||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||||
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
|
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
|
||||||
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
||||||
|
import {useProjectSecurity} from '../lib/hooks/useProjectSecurity';
|
||||||
|
import {useConfig} from '../lib/hooks/useConfig';
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const {activeProject} = useActiveProject();
|
const {activeProject} = useActiveProject();
|
||||||
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
||||||
const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id);
|
const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id);
|
||||||
|
const {securityMetrics} = useProjectSecurity(activeProject?.id);
|
||||||
|
const {data: config} = useConfig();
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
@@ -58,22 +63,35 @@ export default function Index() {
|
|||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Project Disabled - Read-Only Mode</AlertTitle>
|
<AlertTitle>Project Disabled - Read-Only Mode</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
This project has been disabled due to security violations (high bounce or complaint rates). All scheduled
|
This project has been disabled due to security violations (high bounce or complaint rates). All
|
||||||
campaigns and workflows have been cancelled. The project is now in read-only mode - you can view your data
|
scheduled campaigns and workflows have been cancelled. The project is now in read-only mode - you can
|
||||||
but cannot create, update, or delete anything. Please contact support to resolve this issue.
|
view your data but cannot create, update, or delete anything. Please contact support to resolve this
|
||||||
|
issue.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Security Warning Banner */}
|
||||||
|
{activeProject && !activeProject.disabled && securityMetrics && (
|
||||||
|
<SecurityWarningBanner status={securityMetrics.status} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Subscription Warning Banner */}
|
{/* Subscription Warning Banner */}
|
||||||
{activeProject && !activeProject.disabled && !activeProject.subscription && (
|
{activeProject &&
|
||||||
|
!activeProject.disabled &&
|
||||||
|
!activeProject.subscription &&
|
||||||
|
config?.features.billing.enabled && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Upgrade to remove Plunk branding</AlertTitle>
|
<AlertTitle>Upgrade to remove Plunk branding</AlertTitle>
|
||||||
<AlertDescription className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<AlertDescription className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<span className="text-sm">Your emails currently include Plunk branding. Upgrade to a subscription to remove it.</span>
|
<span className="text-sm">
|
||||||
|
Your emails currently include Plunk branding. Upgrade to a subscription to remove it.
|
||||||
|
</span>
|
||||||
<Link href="/settings?tab=billing">
|
<Link href="/settings?tab=billing">
|
||||||
<Button size="sm" className="w-full sm:w-auto">Upgrade Now</Button>
|
<Button size="sm" className="w-full sm:w-auto">
|
||||||
|
Upgrade Now
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import type {Contact, Segment} from '@plunk/db';
|
import type {Contact, Segment} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {ArrowLeft, Calendar, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
|
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
@@ -21,6 +21,7 @@ import useSWR from 'swr';
|
|||||||
import type {FilterCondition} from '@plunk/types';
|
import type {FilterCondition} from '@plunk/types';
|
||||||
import {SegmentSchemas} from '@plunk/shared';
|
import {SegmentSchemas} from '@plunk/shared';
|
||||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface PaginatedContacts {
|
interface PaginatedContacts {
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
@@ -388,22 +389,6 @@ export default function SegmentDetailPage() {
|
|||||||
<CardTitle>Metadata</CardTitle>
|
<CardTitle>Metadata</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-neutral-900">Created</p>
|
|
||||||
<p className="text-sm text-neutral-500">{new Date(segment.createdAt).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
|
|
||||||
<p className="text-sm text-neutral-500">{new Date(segment.updatedAt).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
|
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -411,6 +396,26 @@ export default function SegmentDetailPage() {
|
|||||||
<p className="text-xs text-neutral-500 font-mono break-all">{segment.id}</p>
|
<p className="text-xs text-neutral-500 font-mono break-all">{segment.id}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">Created</p>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<p className="text-sm text-neutral-500">{dayjs(segment.createdAt).fromNow()}</p>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<p className="text-sm text-neutral-500">{dayjs(segment.updatedAt).fromNow()}</p>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import type {Segment} from '@plunk/db';
|
|||||||
import type {FilterCondition} from '@plunk/types';
|
import type {FilterCondition} from '@plunk/types';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {AlertTriangle, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
|
import {AlertTriangle, Calendar, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
// Helper function to count total filters in a condition
|
// Helper function to count total filters in a condition
|
||||||
function countFiltersInCondition(condition: unknown): number {
|
function countFiltersInCondition(condition: unknown): number {
|
||||||
@@ -194,8 +196,22 @@ export default function SegmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
<div className="text-xs text-neutral-500 pt-2 border-t border-neutral-200">
|
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
|
||||||
Created {new Date(segment.createdAt).toLocaleDateString()}
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>Created {formatRelativeTime(segment.createdAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>• Updated {formatRelativeTime(segment.updatedAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -37,7 +37,16 @@ import {
|
|||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {AlertTriangle, CreditCard, Database, Globe, Mail, Settings as SettingsIcon, Users} from 'lucide-react';
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CreditCard,
|
||||||
|
Database,
|
||||||
|
Globe,
|
||||||
|
Mail,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Shield,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
import type {z} from 'zod';
|
import type {z} from 'zod';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
@@ -50,14 +59,16 @@ import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
|
|||||||
import {SmtpSettings} from '../../components/SmtpSettings';
|
import {SmtpSettings} from '../../components/SmtpSettings';
|
||||||
import {DataManagementSettings} from '../../components/DataManagementSettings';
|
import {DataManagementSettings} from '../../components/DataManagementSettings';
|
||||||
import {TeamSettings} from '../../components/TeamSettings';
|
import {TeamSettings} from '../../components/TeamSettings';
|
||||||
|
import {SecuritySettings} from '../../components/SecuritySettings';
|
||||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {useProjects} from '../../lib/hooks/useProject';
|
import {useProjects} from '../../lib/hooks/useProject';
|
||||||
import {useConfig} from '../../lib/hooks/useConfig';
|
import {useConfig} from '../../lib/hooks/useConfig';
|
||||||
import {useUser} from '../../lib/hooks/useUser';
|
import {useUser} from '../../lib/hooks/useUser';
|
||||||
|
import {useProjectSecurity} from '../../lib/hooks/useProjectSecurity';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team';
|
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team' | 'security';
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
id: TabId;
|
id: TabId;
|
||||||
@@ -71,6 +82,7 @@ const buildTabs = (options: {billingEnabled: boolean; smtpEnabled: boolean}): Ta
|
|||||||
const allTabs: Tab[] = [
|
const allTabs: Tab[] = [
|
||||||
{id: 'general', label: 'General', icon: SettingsIcon},
|
{id: 'general', label: 'General', icon: SettingsIcon},
|
||||||
{id: 'team', label: 'Team', icon: Users},
|
{id: 'team', label: 'Team', icon: Users},
|
||||||
|
{id: 'security', label: 'Security', icon: Shield},
|
||||||
{id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled},
|
{id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled},
|
||||||
{id: 'domains', label: 'Domains', icon: Globe},
|
{id: 'domains', label: 'Domains', icon: Globe},
|
||||||
{id: 'smtp', label: 'SMTP', icon: Mail, condition: smtpEnabled},
|
{id: 'smtp', label: 'SMTP', icon: Mail, condition: smtpEnabled},
|
||||||
@@ -103,6 +115,8 @@ export default function Settings() {
|
|||||||
const currentUserMembership = membershipData?.data.find(m => m.userId === user?.id);
|
const currentUserMembership = membershipData?.data.find(m => m.userId === user?.id);
|
||||||
const currentUserRole = currentUserMembership?.role || 'MEMBER';
|
const currentUserRole = currentUserMembership?.role || 'MEMBER';
|
||||||
|
|
||||||
|
const {securityMetrics, isLoading: isLoadingSecurityMetrics} = useProjectSecurity(activeProject?.id);
|
||||||
|
|
||||||
const billingEnabled = config?.features.billing.enabled ?? false;
|
const billingEnabled = config?.features.billing.enabled ?? false;
|
||||||
const smtpEnabled = config?.features.smtp.enabled ?? false;
|
const smtpEnabled = config?.features.smtp.enabled ?? false;
|
||||||
const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false;
|
const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false;
|
||||||
@@ -368,9 +382,9 @@ export default function Settings() {
|
|||||||
{buildTabs({billingEnabled, smtpEnabled}).map(tab => {
|
{buildTabs({billingEnabled, smtpEnabled}).map(tab => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
return (
|
return (
|
||||||
<TabsTrigger key={tab.id} value={tab.id} className="flex items-center gap-2">
|
<TabsTrigger key={tab.id} value={tab.id} className="flex items-center gap-2" title={tab.label}>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{tab.label}
|
<span className="hidden md:inline">{tab.label}</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -647,7 +661,8 @@ export default function Settings() {
|
|||||||
<span className="font-medium">No Active Subscription</span>
|
<span className="font-medium">No Active Subscription</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600">
|
||||||
Start a subscription to unlock premium features and support the development of Plunk.
|
Start a subscription and support the development of Plunk. You will be charged a one-time
|
||||||
|
onboarding fee which will be credited to your first invoice.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -690,6 +705,24 @@ export default function Settings() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Security Tab */}
|
||||||
|
<TabsContent value="security">
|
||||||
|
{securityMetrics ? (
|
||||||
|
<SecuritySettings metrics={securityMetrics} isLoading={isLoadingSecurityMetrics} />
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Security Overview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
{isLoadingSecurityMetrics ? 'Loading...' : 'Unable to load security metrics'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Domains Tab */}
|
{/* Domains Tab */}
|
||||||
<TabsContent value="domains">
|
<TabsContent value="domains">
|
||||||
<DomainsSettings projectId={activeProject.id} />
|
<DomainsSettings projectId={activeProject.id} />
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ export default function TemplateEditorPage() {
|
|||||||
subject: editedTemplate.subject,
|
subject: editedTemplate.subject,
|
||||||
body: editedTemplate.body,
|
body: editedTemplate.body,
|
||||||
from: editedTemplate.from,
|
from: editedTemplate.from,
|
||||||
fromName: editedTemplate.fromName || undefined,
|
fromName: editedTemplate.fromName || null,
|
||||||
replyTo: editedTemplate.replyTo || undefined,
|
replyTo: editedTemplate.replyTo || null,
|
||||||
type: editedTemplate.type,
|
type: editedTemplate.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ export default function TemplateEditorPage() {
|
|||||||
|
|
||||||
{/* Email Body */}
|
{/* Email Body */}
|
||||||
<div>
|
<div>
|
||||||
<Card>
|
<Card className="overflow-visible">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Email Body</CardTitle>
|
<CardTitle>Email Body</CardTitle>
|
||||||
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
||||||
@@ -288,7 +288,6 @@ export default function TemplateEditorPage() {
|
|||||||
<EmailEditor
|
<EmailEditor
|
||||||
value={editedTemplate.body || ''}
|
value={editedTemplate.body || ''}
|
||||||
onChange={body => setEditedTemplate({...editedTemplate, body})}
|
onChange={body => setEditedTemplate({...editedTemplate, body})}
|
||||||
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ export default function CreateTemplatePage() {
|
|||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
from,
|
from,
|
||||||
fromName: fromName || undefined,
|
fromName: fromName || null,
|
||||||
replyTo: replyTo || undefined,
|
replyTo: replyTo || null,
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,17 +179,13 @@ export default function CreateTemplatePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Email Body */}
|
{/* Email Body */}
|
||||||
<Card>
|
<Card className="overflow-visible">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Email Body</CardTitle>
|
<CardTitle>Email Body</CardTitle>
|
||||||
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<EmailEditor
|
<EmailEditor value={body} onChange={setBody} />
|
||||||
value={body}
|
|
||||||
onChange={setBody}
|
|
||||||
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import {
|
|||||||
import type {Template} from '@plunk/db';
|
import type {Template} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
|
import {Calendar, Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface PaginatedTemplates {
|
interface PaginatedTemplates {
|
||||||
templates: Template[];
|
templates: Template[];
|
||||||
@@ -252,8 +254,22 @@ export default function TemplatesPage() {
|
|||||||
<p className="text-xs text-neutral-500 mb-1">From</p>
|
<p className="text-xs text-neutral-500 mb-1">From</p>
|
||||||
<p className="text-sm text-neutral-700">{template.from}</p>
|
<p className="text-sm text-neutral-700">{template.from}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6 text-sm text-neutral-500 pt-2 border-t border-neutral-100">
|
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
|
||||||
<div>Created {new Date(template.createdAt).toLocaleDateString()}</div>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>Created {formatRelativeTime(template.createdAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(template.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>• Updated {formatRelativeTime(template.updatedAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(template.updatedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{template.replyTo && (
|
{template.replyTo && (
|
||||||
<div>
|
<div>
|
||||||
Reply to: <span className="text-neutral-700">{template.replyTo}</span>
|
Reply to: <span className="text-neutral-700">{template.replyTo}</span>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import useSWR from 'swr';
|
|||||||
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
||||||
import {ReactFlowProvider} from '@xyflow/react';
|
import {ReactFlowProvider} from '@xyflow/react';
|
||||||
import {WorkflowSchemas} from '@plunk/shared';
|
import {WorkflowSchemas} from '@plunk/shared';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface WorkflowWithDetails extends Workflow {
|
interface WorkflowWithDetails extends Workflow {
|
||||||
steps: (WorkflowStep & {
|
steps: (WorkflowStep & {
|
||||||
@@ -611,7 +612,12 @@ export default function WorkflowEditorPage() {
|
|||||||
{execution.currentStep?.name ?? '-'}
|
{execution.currentStep?.name ?? '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
|
||||||
{new Date(execution.startedAt).toLocaleString()}
|
<div className="group relative inline-block cursor-help">
|
||||||
|
{dayjs(execution.startedAt).fromNow()}
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(execution.startedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
{(execution.status === 'RUNNING' || execution.status === 'WAITING') && (
|
{(execution.status === 'RUNNING' || execution.status === 'WAITING') && (
|
||||||
|
|||||||
@@ -23,13 +23,15 @@ import {
|
|||||||
import type {Workflow} from '@plunk/db';
|
import type {Workflow} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
|
import {Calendar, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {WorkflowSchemas} from '@plunk/shared';
|
import {WorkflowSchemas} from '@plunk/shared';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface PaginatedWorkflows {
|
interface PaginatedWorkflows {
|
||||||
workflows: (Workflow & {_count?: {steps: number; executions: number}})[];
|
workflows: (Workflow & {_count?: {steps: number; executions: number}})[];
|
||||||
@@ -252,7 +254,23 @@ export default function WorkflowsPage() {
|
|||||||
<span className="font-medium text-neutral-900">{workflow._count?.executions ?? 0}</span>{' '}
|
<span className="font-medium text-neutral-900">{workflow._count?.executions ?? 0}</span>{' '}
|
||||||
executions
|
executions
|
||||||
</div>
|
</div>
|
||||||
<div>Created {new Date(workflow.createdAt).toLocaleDateString()}</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>Created {formatRelativeTime(workflow.createdAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(workflow.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>• Updated {formatRelativeTime(workflow.updatedAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(workflow.updatedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -5,15 +5,32 @@ description: Deploy with Docker Compose
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
1. Download [docker-compose.yml](https://raw.githubusercontent.com/useplunk/plunk/next/docker-compose.yml)
|
||||||
|
2. Configure environment variables
|
||||||
|
3. Run `docker compose up -d`
|
||||||
|
|
||||||
|
## Minimal .env
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/useplunk/plunk.git
|
# Required
|
||||||
cd plunk
|
JWT_SECRET="$(openssl rand -base64 32)"
|
||||||
cp .env.self-host.example .env
|
DB_PASSWORD="your-secure-password"
|
||||||
# Edit .env (see Environment Variables)
|
|
||||||
docker compose up -d
|
# Domains (point these to your server)
|
||||||
|
API_DOMAIN="api.yourdomain.com"
|
||||||
|
DASHBOARD_DOMAIN="app.yourdomain.com"
|
||||||
|
LANDING_DOMAIN="www.yourdomain.com"
|
||||||
|
WIKI_DOMAIN="docs.yourdomain.com"
|
||||||
|
USE_HTTPS="true"
|
||||||
|
|
||||||
|
# AWS SES
|
||||||
|
AWS_SES_REGION="us-east-1"
|
||||||
|
AWS_SES_ACCESS_KEY_ID="your-key"
|
||||||
|
AWS_SES_SECRET_ACCESS_KEY="your-secret"
|
||||||
|
SES_CONFIGURATION_SET="plunk-tracking"
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Environment Variables](/self-hosting/environment-variables) for all configuration options.
|
See [Environment Variables](/self-hosting/environment-variables) for all options.
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
|
|
||||||
@@ -33,7 +50,6 @@ See [Environment Variables](/self-hosting/environment-variables) for all configu
|
|||||||
| 465 | SMTP (implicit TLS) |
|
| 465 | SMTP (implicit TLS) |
|
||||||
| 587 | SMTP (STARTTLS) |
|
| 587 | SMTP (STARTTLS) |
|
||||||
| 9000 | Minio API |
|
| 9000 | Minio API |
|
||||||
| 9001 | Minio Console |
|
|
||||||
|
|
||||||
## Running Individual Services
|
## Running Individual Services
|
||||||
|
|
||||||
|
|||||||
@@ -33,28 +33,7 @@ description: Configure email delivery
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Request Production Access
|
## 2. Create SNS Topic
|
||||||
|
|
||||||
SES starts in sandbox mode (verified addresses only).
|
|
||||||
|
|
||||||
1. Go to SES Console
|
|
||||||
2. Click "Request production access"
|
|
||||||
3. Wait for approval (24-48 hours)
|
|
||||||
|
|
||||||
## 3. Verify Domain
|
|
||||||
|
|
||||||
1. SES Console → Verified Identities → Create identity
|
|
||||||
2. Choose "Domain" → Enter your domain
|
|
||||||
3. Add DNS records provided by AWS
|
|
||||||
4. Wait for verification
|
|
||||||
|
|
||||||
## 4. Enable DKIM (Recommended)
|
|
||||||
|
|
||||||
1. SES Console → Verified Identities → Your domain
|
|
||||||
2. Enable "Easy DKIM"
|
|
||||||
3. Add the 3 CNAME records to your DNS
|
|
||||||
|
|
||||||
## 5. Create SNS Topic
|
|
||||||
|
|
||||||
1. Go to SNS Console → Topics → Create topic
|
1. Go to SNS Console → Topics → Create topic
|
||||||
2. Type: Standard
|
2. Type: Standard
|
||||||
@@ -65,7 +44,7 @@ SES starts in sandbox mode (verified addresses only).
|
|||||||
- Endpoint: `https://api.yourdomain.com/webhooks/sns`
|
- Endpoint: `https://api.yourdomain.com/webhooks/sns`
|
||||||
6. Plunk automatically confirms the subscription. If it fails, check your logs for the confirmation URL.
|
6. Plunk automatically confirms the subscription. If it fails, check your logs for the confirmation URL.
|
||||||
|
|
||||||
## 6. Create Configuration Sets
|
## 3. Create Configuration Sets
|
||||||
|
|
||||||
### Tracking Configuration Set
|
### Tracking Configuration Set
|
||||||
|
|
||||||
@@ -81,7 +60,7 @@ SES starts in sandbox mode (verified addresses only).
|
|||||||
1. Create another set named `plunk-no-tracking`
|
1. Create another set named `plunk-no-tracking`
|
||||||
2. Add event destination with only: **Sends, Deliveries, Bounces, Complaints**
|
2. Add event destination with only: **Sends, Deliveries, Bounces, Complaints**
|
||||||
|
|
||||||
## 7. Configure Environment
|
## 4. Configure Environment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AWS_SES_REGION="us-east-1"
|
AWS_SES_REGION="us-east-1"
|
||||||
@@ -90,3 +69,8 @@ AWS_SES_SECRET_ACCESS_KEY="your-secret-key"
|
|||||||
SES_CONFIGURATION_SET="plunk-tracking"
|
SES_CONFIGURATION_SET="plunk-tracking"
|
||||||
SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking"
|
SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 5. Add Your Domain in Plunk
|
||||||
|
|
||||||
|
Once you have configured AWS SES with the above settings, you can add and verify your domain directly through the Plunk dashboard. Plunk will handle the domain verification and DKIM setup with AWS SES automatically and show you the right records to add to your DNS.
|
||||||
|
|
||||||
|
|||||||
@@ -7,26 +7,25 @@ description: Deploy Plunk on your own infrastructure
|
|||||||
|
|
||||||
- Docker and Docker Compose
|
- Docker and Docker Compose
|
||||||
- AWS SES account (for sending emails)
|
- AWS SES account (for sending emails)
|
||||||
- Domain name (for production)
|
- Domain name with subdomains for each service
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
1. Download the [docker-compose.yml](https://raw.githubusercontent.com/useplunk/plunk/next/docker-compose.yml)
|
||||||
git clone https://github.com/useplunk/plunk.git
|
2. Create environment variables with your configuration (see [Environment Variables](/self-hosting/environment-variables))
|
||||||
cd plunk
|
3. Run `docker compose up -d`
|
||||||
cp .env.self-host.example .env
|
|
||||||
# Edit .env with your settings
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Access
|
## Domain Setup
|
||||||
|
|
||||||
| Service | URL |
|
Configure these subdomains pointing to your server:
|
||||||
|---------|-----|
|
|
||||||
| Dashboard | http://app.localhost |
|
| Subdomain | Service |
|
||||||
| API | http://api.localhost |
|
|-----------|---------|
|
||||||
| Docs | http://docs.localhost |
|
| `app.yourdomain.com` | Dashboard |
|
||||||
| Minio Console | http://localhost:9001 |
|
| `api.yourdomain.com` | API |
|
||||||
|
| `docs.yourdomain.com` | Documentation |
|
||||||
|
| `www.yourdomain.com` | Landing page |
|
||||||
|
| `smtp.yourdomain.com` | SMTP relay (optional) |
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,10 @@
|
|||||||
"fumadocs-openapi": "^10.0.11",
|
"fumadocs-openapi": "^10.0.11",
|
||||||
"fumadocs-ui": "16.0.8",
|
"fumadocs-ui": "16.0.8",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.7",
|
"next": "^16.0.10",
|
||||||
"next-sitemap": "^4.2.3",
|
"next-sitemap": "^4.2.3",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.3",
|
||||||
"shiki": "^3.15.0",
|
"shiki": "^3.15.0",
|
||||||
"unist-util-visit": "^5.0.0"
|
"unist-util-visit": "^5.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -183,6 +183,13 @@ services:
|
|||||||
# If using PEM files, SMTP_DOMAIN is optional
|
# If using PEM files, SMTP_DOMAIN is optional
|
||||||
SMTP_DOMAIN: ${SMTP_DOMAIN:-}
|
SMTP_DOMAIN: ${SMTP_DOMAIN:-}
|
||||||
|
|
||||||
|
# Platform emails
|
||||||
|
PLUNK_API_KEY: ${PLUNK_API_KEY:-}
|
||||||
|
PLUNK_FROM_ADDRESS: ${PLUNK_FROM_ADDRESS:-}
|
||||||
|
|
||||||
|
# Security
|
||||||
|
AUTO_PROJECT_DISABLE: ${AUTO_PROJECT_DISABLE:-false}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Persistent storage for application data
|
# Persistent storage for application data
|
||||||
- plunk_data:/app/data
|
- plunk_data:/app/data
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres
|
image: postgres:16-alpine
|
||||||
ports:
|
ports:
|
||||||
- 55432:5432
|
- 55432:5432
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "plunk",
|
"name": "plunk",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "events_projectId_name_createdAt_idx" ON "events"("projectId", "name", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "events_contactId_createdAt_idx" ON "events"("contactId", "createdAt");
|
||||||
@@ -611,6 +611,8 @@ model Event {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([projectId, contactId, name, createdAt]) // For event-based segment queries (fast!)
|
@@index([projectId, contactId, name, createdAt]) // For event-based segment queries (fast!)
|
||||||
@@index([contactId, name, createdAt]) // For per-contact event lookups
|
@@index([contactId, name, createdAt]) // For per-contact event lookups
|
||||||
|
@@index([projectId, name, createdAt]) // For event stats and analytics by type over time
|
||||||
|
@@index([contactId, createdAt]) // For contact activity timeline (recent events)
|
||||||
@@index([data(ops: JsonbOps)], type: Gin) // GIN index for event data queries
|
@@index([data(ops: JsonbOps)], type: Gin) // GIN index for event data queries
|
||||||
@@map("events")
|
@@map("events")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,13 @@
|
|||||||
"react-email": "5.0.1",
|
"react-email": "5.0.1",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^19.2.1",
|
|
||||||
"react-dom": "^19.2.1"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@plunk/db": "*",
|
"@plunk/db": "*",
|
||||||
"@plunk/types": "*",
|
"@plunk/types": "*",
|
||||||
"@react-email/components": "^1.0.0"
|
"@react-email/components": "^1.0.0",
|
||||||
|
"@react-email/tailwind": "^2.0.1",
|
||||||
|
"react": "^19.2.1",
|
||||||
|
"react-dom": "^19.2.1"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.js"
|
".": "./dist/index.js"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {Body, Container, Head, Html, Tailwind} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface EmailLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tailwindConfig = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
200: '#bfdbfe',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
900: '#1e3a8a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: [
|
||||||
|
'ui-sans-serif',
|
||||||
|
'system-ui',
|
||||||
|
'-apple-system',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'Segoe UI',
|
||||||
|
'Roboto',
|
||||||
|
'Helvetica Neue',
|
||||||
|
'Arial',
|
||||||
|
'sans-serif',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmailLayout({children}: EmailLayoutProps) {
|
||||||
|
return (
|
||||||
|
<Tailwind config={tailwindConfig}>
|
||||||
|
<Html>
|
||||||
|
<Head>
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<meta name="supported-color-schemes" content="light" />
|
||||||
|
</Head>
|
||||||
|
<Body className="m-0 bg-gray-50 p-0 font-sans antialiased">
|
||||||
|
<Container
|
||||||
|
className="mx-auto my-8 max-w-[600px] overflow-hidden rounded-lg bg-white shadow-sm"
|
||||||
|
style={{border: '1px solid #e5e7eb'}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
</Tailwind>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {Link, Section, Text} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
projectId?: string;
|
||||||
|
landingUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer({projectId, landingUrl = 'https://www.useplunk.com'}: FooterProps) {
|
||||||
|
return (
|
||||||
|
<Section className="border-t border-gray-100 bg-gray-50 px-8 py-8">
|
||||||
|
<Text className="mb-4 mt-0 text-center text-xs leading-relaxed text-gray-600">
|
||||||
|
This email was sent by Plunk. .
|
||||||
|
</Text>
|
||||||
|
{projectId && (
|
||||||
|
<Text className="mb-4 mt-0 text-center text-xs leading-relaxed text-gray-400">
|
||||||
|
Project ID: <span className="font-mono">{projectId}</span>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text className="mb-0 mt-0 text-center text-xs leading-relaxed text-gray-500">
|
||||||
|
<Link href={landingUrl} className="text-gray-500 no-underline hover:text-gray-700">
|
||||||
|
Plunk
|
||||||
|
</Link>
|
||||||
|
{' • '}
|
||||||
|
<Link
|
||||||
|
href={`${landingUrl}/privacy`}
|
||||||
|
className="text-gray-500 no-underline hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Privacy
|
||||||
|
</Link>
|
||||||
|
{' • '}
|
||||||
|
<Link
|
||||||
|
href={`${landingUrl}/terms`}
|
||||||
|
className="text-gray-500 no-underline hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Terms
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import {Img} from '@react-email/components';
|
import {Img, Section} from '@react-email/components';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
return (
|
return (
|
||||||
<>
|
<Section className="border-b border-gray-100 bg-white px-8 py-8">
|
||||||
<Img src="https://www.swyp.be/favicon/web-app-manifest-192x192.png" alt="Swyp Logo" width="40" height="40" />
|
<Img
|
||||||
</>
|
src="https://next.useplunk.com/assets/logo.png"
|
||||||
|
alt="Plunk"
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import {Heading, Link, Section, Text} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
import {EmailLayout} from '../common/EmailLayout';
|
||||||
|
import {Footer} from '../common/Footer';
|
||||||
|
import {Header} from '../common/Header';
|
||||||
|
|
||||||
|
interface BillingLimitExceededEmailProps {
|
||||||
|
projectName: string;
|
||||||
|
projectId: string;
|
||||||
|
usage: number;
|
||||||
|
limit: number;
|
||||||
|
sourceType: string;
|
||||||
|
dashboardUrl?: string;
|
||||||
|
landingUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BillingLimitExceededEmail({
|
||||||
|
projectName = 'My Project',
|
||||||
|
projectId = 'proj_example123',
|
||||||
|
usage = 10000,
|
||||||
|
limit = 10000,
|
||||||
|
sourceType = 'Transactional',
|
||||||
|
dashboardUrl = 'https://app.useplunk.com',
|
||||||
|
landingUrl = 'https://www.useplunk.com',
|
||||||
|
}: BillingLimitExceededEmailProps) {
|
||||||
|
return (
|
||||||
|
<EmailLayout>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<Section className="px-8 pb-10 pt-10">
|
||||||
|
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">
|
||||||
|
Email sending paused
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
|
||||||
|
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has reached your configured
|
||||||
|
monthly billing limit. Email sending has been paused to prevent charges beyond your set limit.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
|
||||||
|
<Section className="bg-gray-50 px-6 py-4">
|
||||||
|
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Usage this month
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="px-6 py-6">
|
||||||
|
<Section className="mb-6">
|
||||||
|
<Section className="mb-2 flex items-baseline justify-between">
|
||||||
|
<Text className="mb-0 mt-0 text-sm text-gray-600">Emails sent</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm font-medium text-gray-900">
|
||||||
|
{usage.toLocaleString()} / {limit.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="h-2 overflow-hidden rounded-full bg-gray-200">
|
||||||
|
<Section className="h-full bg-gray-900" style={{width: '100%'}} />
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
<Text className="mb-0 mt-0 text-xs text-gray-500">{sourceType} emails</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-8 rounded-lg bg-red-50 px-6 py-4" style={{border: '1px solid #fca5a5'}}>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-red-900">
|
||||||
|
All email sending is currently blocked. Your usage will reset at the start of next month, or you can
|
||||||
|
increase your billing limit to resume immediately.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">How to resume sending</Heading>
|
||||||
|
|
||||||
|
<Section className="mb-8">
|
||||||
|
<Section className="mb-3">
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Increase your billing limit</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Resume sending immediately by adjusting your monthly limit in billing settings
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-3">
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Wait for monthly reset</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Your usage will automatically reset at the start of next month
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Contact support</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Need immediate help? Our team is here to assist you
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={`${dashboardUrl}/settings?tab=billing`}
|
||||||
|
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
|
||||||
|
>
|
||||||
|
Adjust billing limit
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Link href={dashboardUrl} className="text-sm text-gray-500" style={{textDecoration: 'none'}}>
|
||||||
|
View project dashboard →
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Footer projectId={projectId} landingUrl={landingUrl} />
|
||||||
|
</EmailLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BillingLimitExceededEmail;
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {Heading, Link, Section, Text} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
import {EmailLayout} from '../common/EmailLayout';
|
||||||
|
import {Footer} from '../common/Footer';
|
||||||
|
import {Header} from '../common/Header';
|
||||||
|
|
||||||
|
interface BillingLimitWarningEmailProps {
|
||||||
|
projectName: string;
|
||||||
|
projectId: string;
|
||||||
|
usage: number;
|
||||||
|
limit: number;
|
||||||
|
percentage: number;
|
||||||
|
sourceType: string;
|
||||||
|
dashboardUrl?: string;
|
||||||
|
landingUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BillingLimitWarningEmail({
|
||||||
|
projectName = 'My Project',
|
||||||
|
projectId = 'proj_example123',
|
||||||
|
usage = 8500,
|
||||||
|
limit = 10000,
|
||||||
|
percentage = 85,
|
||||||
|
sourceType = 'Transactional',
|
||||||
|
dashboardUrl = 'https://app.useplunk.com',
|
||||||
|
landingUrl = 'https://www.useplunk.com',
|
||||||
|
}: BillingLimitWarningEmailProps) {
|
||||||
|
const percentageRounded = Math.round(percentage);
|
||||||
|
const remaining = limit - usage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmailLayout>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<Section className="px-8 pb-10 pt-10">
|
||||||
|
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">Usage limit warning</Heading>
|
||||||
|
|
||||||
|
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
|
||||||
|
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has used{' '}
|
||||||
|
<strong className="font-medium text-gray-900">{percentageRounded}%</strong> of your configured monthly billing
|
||||||
|
limit.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
|
||||||
|
<Section className="bg-gray-50 px-6 py-4">
|
||||||
|
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Usage this month
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="px-6 py-6">
|
||||||
|
<Section className="mb-6">
|
||||||
|
<Section className="mb-2 flex items-baseline justify-between">
|
||||||
|
<Text className="mb-0 mt-0 text-sm text-gray-600">Emails sent</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm font-medium text-gray-900">
|
||||||
|
{usage.toLocaleString()} / {limit.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="h-2 overflow-hidden rounded-full bg-gray-200">
|
||||||
|
<div className="h-full bg-gray-900" style={{width: `${percentageRounded}%`, height: '100%'}} />
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
<Text className="mb-0 mt-0 text-xs text-gray-500">{sourceType} emails</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-8 rounded-lg bg-amber-50 px-6 py-4" style={{border: '1px solid #fbbf24'}}>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-amber-900">
|
||||||
|
When you reach 100%, email sending will be paused to prevent charges beyond your configured limit. Your
|
||||||
|
usage will reset at the start of next month.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">Recommended actions</Heading>
|
||||||
|
|
||||||
|
<Section className="mb-8">
|
||||||
|
<Section className="mb-3">
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Increase your billing limit</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Adjust your monthly limit in billing settings to continue sending
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-3">
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Monitor your usage</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Track your sending patterns in the dashboard
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Optimize your sending</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Review and reduce email volume where possible
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={`${dashboardUrl}/settings?tab=billing`}
|
||||||
|
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
|
||||||
|
>
|
||||||
|
Adjust billing limit
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Link href={dashboardUrl} className="text-sm text-gray-500" style={{textDecoration: 'none'}}>
|
||||||
|
View project dashboard →
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Footer projectId={projectId} landingUrl={landingUrl} />
|
||||||
|
</EmailLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BillingLimitWarningEmail;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {Heading, Link, Section, Text} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
import {EmailLayout} from '../common/EmailLayout';
|
||||||
|
import {Footer} from '../common/Footer';
|
||||||
|
import {Header} from '../common/Header';
|
||||||
|
|
||||||
|
interface ProjectDisabledEmailProps {
|
||||||
|
projectName: string;
|
||||||
|
projectId: string;
|
||||||
|
violations: string[];
|
||||||
|
dashboardUrl?: string;
|
||||||
|
landingUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectDisabledEmail({
|
||||||
|
projectName = 'My Project',
|
||||||
|
projectId = 'proj_example123',
|
||||||
|
violations = ['Bounce rate exceeded 10% threshold', 'Complaint rate exceeded 0.5% threshold'],
|
||||||
|
dashboardUrl = 'https://app.useplunk.com',
|
||||||
|
landingUrl = 'https://www.useplunk.com',
|
||||||
|
}: ProjectDisabledEmailProps) {
|
||||||
|
return (
|
||||||
|
<EmailLayout>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<Section className="px-8 pb-10 pt-10">
|
||||||
|
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">Project disabled</Heading>
|
||||||
|
|
||||||
|
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
|
||||||
|
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has been automatically
|
||||||
|
disabled to protect your sender reputation.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
|
||||||
|
<Section className="bg-gray-50 px-6 py-4">
|
||||||
|
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Issues detected
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="px-6 py-6">
|
||||||
|
{violations.map((violation, index) => (
|
||||||
|
<Section key={index} className="mb-3 last:mb-0">
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-700">{violation}</Text>
|
||||||
|
</Section>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-8 rounded-lg bg-red-50 px-6 py-4" style={{border: '1px solid #fca5a5'}}>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-red-900">
|
||||||
|
High bounce or complaint rates can severely damage your sender reputation and email deliverability. We've
|
||||||
|
disabled your project to prevent further issues.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">Steps to restore your project</Heading>
|
||||||
|
|
||||||
|
<Section className="mb-8">
|
||||||
|
<Section className="mb-3">
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Review your email lists</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Remove invalid or unengaged contacts
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-3">
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Verify recipient consent</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Ensure you have proper consent from all recipients
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Check email content</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Verify your content follows best practices and isn't triggering spam filters
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={dashboardUrl}
|
||||||
|
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
|
||||||
|
>
|
||||||
|
View project dashboard
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Footer projectId={projectId} landingUrl={landingUrl} />
|
||||||
|
</EmailLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectDisabledEmail;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export {ProjectDisabledEmail} from './ProjectDisabled';
|
||||||
|
export {BillingLimitWarningEmail} from './BillingLimitWarning';
|
||||||
|
export {BillingLimitExceededEmail} from './BillingLimitExceeded';
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './send';
|
export * from './send';
|
||||||
|
export * from './notify';
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {render} from '@react-email/components';
|
||||||
|
import type {ReactElement} from 'react';
|
||||||
|
import {sendEmail} from './send';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if platform email notifications are enabled
|
||||||
|
* Requires PLUNK_API_KEY to be set in environment
|
||||||
|
*/
|
||||||
|
export function isPlatformEmailEnabled(): boolean {
|
||||||
|
return !!process.env.PLUNK_API_KEY && !!process.env.PLUNK_FROM_ADDRESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a platform notification email (only if PLUNK_API_KEY is configured)
|
||||||
|
* @param to - Recipient email address
|
||||||
|
* @param subject - Email subject line
|
||||||
|
* @param template - React email template component
|
||||||
|
*/
|
||||||
|
export async function sendPlatformEmail(to: string, subject: string, template: ReactElement): Promise<void> {
|
||||||
|
// Skip if platform emails are not enabled
|
||||||
|
if (!isPlatformEmailEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Render React email template to HTML
|
||||||
|
const html = await render(template);
|
||||||
|
|
||||||
|
// Send email using the platform
|
||||||
|
await sendEmail({
|
||||||
|
to,
|
||||||
|
from: process.env.PLUNK_FROM_ADDRESS as string,
|
||||||
|
subject,
|
||||||
|
body: html,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't throw - notifications should not break the main flow
|
||||||
|
console.error('[Platform Email] Failed to send notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
interface SendEmailParams {
|
interface SendEmailParams {
|
||||||
to: string;
|
to: string;
|
||||||
|
from: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendEmail({to, subject, body}: SendEmailParams) {
|
export async function sendEmail({to, from, subject, body}: SendEmailParams) {
|
||||||
const apiUrl = process.env.API_URI ?? 'http://localhost:8080';
|
const apiUrl = process.env.API_URI;
|
||||||
const res = await fetch(`${apiUrl}/v1/send`, {
|
const res = await fetch(`${apiUrl}/v1/send`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -14,6 +15,7 @@ export async function sendEmail({to, subject, body}: SendEmailParams) {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
to,
|
to,
|
||||||
|
from,
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ export const TemplateSchemas = {
|
|||||||
subject: z.string().min(1),
|
subject: z.string().min(1),
|
||||||
body: z.string().min(1),
|
body: z.string().min(1),
|
||||||
from: email,
|
from: email,
|
||||||
fromName: z.string().max(100).optional(),
|
fromName: z.string().max(100).nullish(),
|
||||||
replyTo: email.optional(),
|
replyTo: email.nullish(),
|
||||||
type: z.nativeEnum(TemplateType).default('MARKETING'),
|
type: z.nativeEnum(TemplateType).default('MARKETING'),
|
||||||
}),
|
}),
|
||||||
update: z.object({
|
update: z.object({
|
||||||
@@ -152,8 +152,8 @@ export const TemplateSchemas = {
|
|||||||
subject: z.string().min(1).optional(),
|
subject: z.string().min(1).optional(),
|
||||||
body: z.string().min(1).optional(),
|
body: z.string().min(1).optional(),
|
||||||
from: email.optional(),
|
from: email.optional(),
|
||||||
fromName: z.string().max(100).optional(),
|
fromName: z.string().max(100).nullish(),
|
||||||
replyTo: email.optional(),
|
replyTo: email.nullish(),
|
||||||
type: z.nativeEnum(TemplateType).optional(),
|
type: z.nativeEnum(TemplateType).optional(),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -282,8 +282,8 @@ export const CampaignSchemas = {
|
|||||||
subject: z.string().min(1),
|
subject: z.string().min(1),
|
||||||
body: z.string().min(1),
|
body: z.string().min(1),
|
||||||
from: email,
|
from: email,
|
||||||
fromName: z.string().max(100).optional(),
|
fromName: z.string().max(100).nullish(),
|
||||||
replyTo: email.optional(),
|
replyTo: email.nullish(),
|
||||||
audienceType: z.nativeEnum(CampaignAudienceType),
|
audienceType: z.nativeEnum(CampaignAudienceType),
|
||||||
audienceCondition: filterConditionSchema.optional(),
|
audienceCondition: filterConditionSchema.optional(),
|
||||||
segmentId: uuid.optional(),
|
segmentId: uuid.optional(),
|
||||||
@@ -297,8 +297,8 @@ export const CampaignSchemas = {
|
|||||||
subject: z.string().optional(),
|
subject: z.string().optional(),
|
||||||
body: z.string().optional(),
|
body: z.string().optional(),
|
||||||
from: z.string().optional(),
|
from: z.string().optional(),
|
||||||
fromName: z.string().max(100).optional(),
|
fromName: z.string().max(100).nullish(),
|
||||||
replyTo: z.string().optional(),
|
replyTo: z.string().nullish(),
|
||||||
audienceType: z.nativeEnum(CampaignAudienceType).optional(),
|
audienceType: z.nativeEnum(CampaignAudienceType).optional(),
|
||||||
audienceCondition: filterConditionSchema.optional(),
|
audienceCondition: filterConditionSchema.optional(),
|
||||||
segmentId: z.string().optional(),
|
segmentId: z.string().optional(),
|
||||||
@@ -340,16 +340,14 @@ export const ActionSchemas = {
|
|||||||
template: uuid.optional(),
|
template: uuid.optional(),
|
||||||
subscribed: z.boolean().optional().default(false),
|
subscribed: z.boolean().optional().default(false),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
from: z
|
from: z.union([
|
||||||
.union([
|
|
||||||
email, // Simple email string (backward compatible)
|
email, // Simple email string (backward compatible)
|
||||||
z.object({
|
z.object({
|
||||||
// Object with name and email
|
// Object with name and email
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
email: email,
|
email: email,
|
||||||
}),
|
}),
|
||||||
])
|
]),
|
||||||
.optional(),
|
|
||||||
reply: email.optional(),
|
reply: email.optional(),
|
||||||
headers: z.record(z.string().max(998)).optional(),
|
headers: z.record(z.string().max(998)).optional(),
|
||||||
data: jsonSchema.optional(),
|
data: jsonSchema.optional(),
|
||||||
|
|||||||
@@ -55,3 +55,40 @@ export interface SegmentMembershipComputeResult {
|
|||||||
removed: number;
|
removed: number;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security status types
|
||||||
|
export interface SecurityRateData {
|
||||||
|
total: number;
|
||||||
|
bounces: number;
|
||||||
|
complaints: number;
|
||||||
|
bounceRate: number;
|
||||||
|
complaintRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityStatus {
|
||||||
|
projectId: string;
|
||||||
|
isHealthy: boolean;
|
||||||
|
shouldDisable: boolean;
|
||||||
|
sevenDay: SecurityRateData;
|
||||||
|
allTime: SecurityRateData;
|
||||||
|
violations: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityThresholds {
|
||||||
|
MIN_EMAILS_FOR_ENFORCEMENT: number;
|
||||||
|
BOUNCE_7DAY_WARNING: number;
|
||||||
|
BOUNCE_7DAY_CRITICAL: number;
|
||||||
|
BOUNCE_ALLTIME_WARNING: number;
|
||||||
|
BOUNCE_ALLTIME_CRITICAL: number;
|
||||||
|
COMPLAINT_7DAY_WARNING: number;
|
||||||
|
COMPLAINT_7DAY_CRITICAL: number;
|
||||||
|
COMPLAINT_ALLTIME_WARNING: number;
|
||||||
|
COMPLAINT_ALLTIME_CRITICAL: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSecurityMetrics {
|
||||||
|
status: SecurityStatus;
|
||||||
|
thresholds: SecurityThresholds;
|
||||||
|
isDisabled: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.1"
|
"react-dom": "^19.2.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react-day-picker": "^8.10.0",
|
"react-day-picker": "^8.10.0",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
|
"recharts": "^3.5.1",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {useRouter} from 'next/router';
|
||||||
|
|
||||||
export interface LoaderProps {
|
export interface LoaderProps {
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -9,6 +11,28 @@ export interface LoaderProps {
|
|||||||
* Full-screen loader component with animated logo and spinner
|
* Full-screen loader component with animated logo and spinner
|
||||||
*/
|
*/
|
||||||
export function Loader({message = 'Loading...', showLogo = true}: LoaderProps) {
|
export function Loader({message = 'Loading...', showLogo = true}: LoaderProps) {
|
||||||
|
const [showClearCache, setShowClearCache] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowClearCache(true);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearCache = () => {
|
||||||
|
document.cookie.split(';').forEach(c => {
|
||||||
|
document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/');
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
|
||||||
|
void router.push('/auth/login');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex items-center justify-center bg-white">
|
<div className="h-screen flex items-center justify-center bg-white">
|
||||||
<div className="text-center animate-in fade-in duration-500">
|
<div className="text-center animate-in fade-in duration-500">
|
||||||
@@ -48,6 +72,19 @@ export function Loader({message = 'Loading...', showLogo = true}: LoaderProps) {
|
|||||||
|
|
||||||
{/* Loading message */}
|
{/* Loading message */}
|
||||||
{message && <p className="text-sm font-medium text-neutral-700 animate-in fade-in duration-1000">{message}</p>}
|
{message && <p className="text-sm font-medium text-neutral-700 animate-in fade-in duration-1000">{message}</p>}
|
||||||
|
|
||||||
|
{/* Clear cache button - appears after 5 seconds */}
|
||||||
|
{showClearCache && (
|
||||||
|
<div className="mt-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
|
<p className="text-xs text-neutral-500 mb-3">Taking too long?</p>
|
||||||
|
<button
|
||||||
|
onClick={handleClearCache}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-neutral-700 bg-neutral-100 hover:bg-neutral-200 rounded-lg transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Clear cache and reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user