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)
|
||||
# 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)
|
||||
# ========================================
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
IMAGE="ghcr.io/${{ github.repository }}"
|
||||
|
||||
if [[ "${{ steps.check-release.outputs.is_release }}" == "true" ]]; then
|
||||
# This is a release: use semver tags
|
||||
# This is a release: use semver tags + latest
|
||||
TAG="${{ steps.check-release.outputs.release_tag }}"
|
||||
VERSION="${TAG#v}"
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
@@ -70,9 +70,9 @@ jobs:
|
||||
TAGS="${VERSION},${MAJOR}.${MINOR},${MAJOR},latest"
|
||||
echo "Building RELEASE with tags: $TAGS"
|
||||
else
|
||||
# Regular commit: use SHA tags
|
||||
# Regular commit: use SHA tags only (no latest)
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
TAGS="sha-${SHORT_SHA},latest"
|
||||
TAGS="sha-${SHORT_SHA}"
|
||||
echo "Building COMMIT with tags: $TAGS"
|
||||
fi
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
# Merge platform-specific images into multi-arch manifest
|
||||
merge:
|
||||
needs: [prepare, build]
|
||||
needs: [ prepare, build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.1.0"
|
||||
".": "0.2.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
@@ -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_METER_EVENT_NAME`
|
||||
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
||||
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
|
||||
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
|
||||
|
||||
**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).
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@ AWS_SES_SECRET_ACCESS_KEY=
|
||||
SES_CONFIGURATION_SET=plunk-configuration-set # Default: with open/click tracking
|
||||
SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optional: without tracking (enables toggle in UI)
|
||||
|
||||
# Email sending rate limit (emails per second)
|
||||
# If not set, automatically fetches from AWS SES account quota (recommended)
|
||||
# Set this to override AWS quota (useful for setting lower limits or testing)
|
||||
# Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts)
|
||||
# EMAIL_RATE_LIMIT_PER_SECOND=14
|
||||
|
||||
# ==============================================================================
|
||||
# OAuth (Optional - for social login)
|
||||
# ==============================================================================
|
||||
@@ -70,3 +76,11 @@ STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_ONBOARDING= # Optional: One-time onboarding fee price ID (e.g., price_xxxxx)
|
||||
STRIPE_PRICE_EMAIL_USAGE= # Required: Metered price ID for pay-per-email billing
|
||||
STRIPE_METER_EVENT_NAME=emails # Meter event name (API key from your Stripe meter, default: emails)
|
||||
|
||||
# ==============================================================================
|
||||
# Security (Optional)
|
||||
# ==============================================================================
|
||||
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
|
||||
# Set to 'false' to disable automatic project suspension (useful for self-hosters who manage manually)
|
||||
# Default: true (automatic project disabling enabled)
|
||||
# AUTO_PROJECT_DISABLE=true
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('Actions API Integration Tests', () => {
|
||||
it('should validate subject and body required when no template', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
from: '[email protected]',
|
||||
// Missing subject, body, and template
|
||||
});
|
||||
|
||||
@@ -169,6 +170,7 @@ describe('Actions API Integration Tests', () => {
|
||||
it('should accept to as string (backward compatible)', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
from: '[email protected]',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
});
|
||||
@@ -182,6 +184,7 @@ describe('Actions API Integration Tests', () => {
|
||||
name: 'Jane Doe',
|
||||
email: '[email protected]',
|
||||
},
|
||||
from: '[email protected]',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
});
|
||||
@@ -194,6 +197,7 @@ describe('Actions API Integration Tests', () => {
|
||||
to: {
|
||||
email: '[email protected]',
|
||||
},
|
||||
from: '[email protected]',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
});
|
||||
@@ -204,6 +208,7 @@ describe('Actions API Integration Tests', () => {
|
||||
it('should accept to as array of strings', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: ['[email protected]', '[email protected]'],
|
||||
from: '[email protected]',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
});
|
||||
@@ -217,6 +222,7 @@ describe('Actions API Integration Tests', () => {
|
||||
{name: 'Jane Doe', email: '[email protected]'},
|
||||
{name: 'John Smith', email: '[email protected]'},
|
||||
],
|
||||
from: '[email protected]',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
});
|
||||
@@ -227,6 +233,7 @@ describe('Actions API Integration Tests', () => {
|
||||
it('should accept to as mixed array of strings and objects', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: ['[email protected]', {name: 'John Smith', email: '[email protected]'}],
|
||||
from: '[email protected]',
|
||||
subject: '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,
|
||||
LANDING_URI,
|
||||
NODE_ENV,
|
||||
PLUNK_ENABLED,
|
||||
PORT,
|
||||
S3_ENABLED,
|
||||
SMTP_ENABLED,
|
||||
STRIPE_ENABLED,
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
WIKI_URI,
|
||||
WIKI_URI
|
||||
} from './app/constants.js';
|
||||
import {Actions} from './controllers/Actions.js';
|
||||
import {Activity} from './controllers/Activity.js';
|
||||
@@ -309,7 +310,7 @@ server.app.use((error: Error, req: Request, res: Response, _next: NextFunction)
|
||||
// Global error handlers to prevent server crashes
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
signale.error('Unhandled Promise Rejection:', reason);
|
||||
console.error('Promise:', promise);
|
||||
signale.error('Promise:', promise);
|
||||
// Don't exit the process - just log the error
|
||||
});
|
||||
|
||||
@@ -351,6 +352,11 @@ void prisma.$connect().then(async () => {
|
||||
? 'Per-project tracking toggle enabled'
|
||||
: 'Always tracking or always no-tracking',
|
||||
},
|
||||
{
|
||||
name: 'Platform emails',
|
||||
enabled: PLUNK_ENABLED,
|
||||
details: PLUNK_ENABLED ? 'Platform email notifications enabled' : 'PLUNK_API_KEY not configured',
|
||||
},
|
||||
];
|
||||
|
||||
const rows = features.map(f => ({
|
||||
|
||||
@@ -45,6 +45,13 @@ export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
|
||||
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
|
||||
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
|
||||
|
||||
// Email Processing Rate Limit (optional override)
|
||||
// If not set, will automatically fetch from AWS SES account quota
|
||||
// Set this to override AWS quota (useful for setting lower limits or testing)
|
||||
export const EMAIL_RATE_LIMIT_PER_SECOND = process.env.EMAIL_RATE_LIMIT_PER_SECOND
|
||||
? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND)
|
||||
: undefined;
|
||||
|
||||
// Storage
|
||||
export const REDIS_URL = validateEnv('REDIS_URL');
|
||||
export const DATABASE_URL = validateEnv('DATABASE_URL');
|
||||
@@ -87,3 +94,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
|
||||
export const SMTP_ENABLED =
|
||||
process.env.SMTP_ENABLED === 'true' || (SMTP_DOMAIN !== 'localhost' && NODE_ENV !== 'development');
|
||||
|
||||
export const PLUNK_API_KEY = validateEnv('PLUNK_API_KEY', '');
|
||||
export const PLUNK_FROM_ADDRESS = validateEnv('PLUNK_FROM_ADDRESS', '');
|
||||
export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
|
||||
|
||||
// Security (optional)
|
||||
// Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
|
||||
// Useful for self-hosters who want to manage project status manually
|
||||
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
|
||||
|
||||
@@ -9,7 +9,7 @@ import {ContactService} from '../services/ContactService.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {EmailService} from '../services/EmailService.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';
|
||||
|
||||
/**
|
||||
@@ -55,6 +55,21 @@ export class Actions {
|
||||
// Zod validation - errors automatically handled by global error handler
|
||||
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
|
||||
|
||||
// Prevent manual tracking of reserved system events
|
||||
if (EventService.isReservedEvent(event)) {
|
||||
throw new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: `Event name "${event}" is reserved for system use and cannot be manually tracked`,
|
||||
code: 'reserved_event',
|
||||
received: event,
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
}
|
||||
|
||||
// Create or update contact with persistent data only
|
||||
// ContactService.upsert will filter out non-persistent fields
|
||||
const contact = await ContactService.upsert(
|
||||
@@ -223,14 +238,21 @@ export class Actions {
|
||||
templateId = templateRecord.id;
|
||||
}
|
||||
|
||||
// Verify 'from' domain is verified if provided
|
||||
const senderEmail = emailFrom || '[email protected]'; // Default sender
|
||||
|
||||
// Only verify custom domains (not the default [email protected])
|
||||
if (emailFrom && emailFrom !== '[email protected]') {
|
||||
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
|
||||
if (!emailFrom) {
|
||||
throw new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'from',
|
||||
message: 'Sender email is required either in request or template',
|
||||
code: 'required',
|
||||
},
|
||||
],
|
||||
'Could not parse sender email',
|
||||
);
|
||||
}
|
||||
|
||||
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
|
||||
|
||||
const replyToEmail = emailReplyTo;
|
||||
|
||||
const timestamp = new Date();
|
||||
@@ -281,7 +303,7 @@ export class Actions {
|
||||
contactId: contact.id,
|
||||
subject: renderedSubject,
|
||||
body: renderedBody,
|
||||
from: senderEmail,
|
||||
from: emailFrom,
|
||||
fromName: emailFromName,
|
||||
toName: recipient.name,
|
||||
replyTo: replyToEmail,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import {CampaignSchemas} from '@plunk/shared';
|
||||
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
@@ -97,7 +97,7 @@ export class Campaigns {
|
||||
@CatchAsync
|
||||
private async get(req: Request, res: Response, _next: NextFunction) {
|
||||
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!);
|
||||
|
||||
@@ -116,7 +116,7 @@ export class Campaigns {
|
||||
@CatchAsync
|
||||
private async update(req: Request, res: Response, _next: NextFunction) {
|
||||
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} =
|
||||
req.body;
|
||||
|
||||
@@ -162,7 +162,7 @@ export class Campaigns {
|
||||
@CatchAsync
|
||||
private async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
await CampaignService.delete(auth.projectId, id!);
|
||||
|
||||
@@ -181,7 +181,7 @@ export class Campaigns {
|
||||
@CatchAsync
|
||||
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||
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!);
|
||||
|
||||
@@ -201,7 +201,7 @@ export class Campaigns {
|
||||
@CatchAsync
|
||||
private async send(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const scheduledFor = req.body?.scheduledFor;
|
||||
|
||||
// Parse scheduledFor if provided
|
||||
@@ -232,7 +232,7 @@ export class Campaigns {
|
||||
@CatchAsync
|
||||
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
||||
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!);
|
||||
|
||||
@@ -252,7 +252,7 @@ export class Campaigns {
|
||||
@CatchAsync
|
||||
private async stats(req: Request, res: Response, _next: NextFunction) {
|
||||
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!);
|
||||
|
||||
@@ -271,7 +271,7 @@ export class Campaigns {
|
||||
@CatchAsync
|
||||
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
||||
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);
|
||||
|
||||
await CampaignService.sendTest(auth.projectId, id!, email);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import multer from 'multer';
|
||||
import signale from 'signale';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth} from '../middleware/auth.js';
|
||||
@@ -63,7 +64,7 @@ export class Contacts {
|
||||
count: fieldsWithTypes.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to get available fields:', error);
|
||||
signale.error('[CONTACTS] Failed to get available fields:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
||||
});
|
||||
@@ -97,7 +98,7 @@ export class Contacts {
|
||||
limit,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to get field values:', error);
|
||||
signale.error('[CONTACTS] Failed to get field values:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get field values',
|
||||
});
|
||||
@@ -288,7 +289,7 @@ export class Contacts {
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to queue import:', error);
|
||||
signale.error('[CONTACTS] Failed to queue import:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue import',
|
||||
});
|
||||
@@ -318,7 +319,7 @@ export class Contacts {
|
||||
|
||||
return res.status(200).json(status);
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to get import status:', error);
|
||||
signale.error('[CONTACTS] Failed to get import status:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get import status',
|
||||
});
|
||||
@@ -345,7 +346,7 @@ export class Contacts {
|
||||
const usage = await ContactService.getFieldUsage(auth.projectId!, field);
|
||||
return res.status(200).json(usage);
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to get field usage:', error);
|
||||
signale.error('[CONTACTS] Failed to get field usage:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get field usage',
|
||||
});
|
||||
@@ -372,7 +373,7 @@ export class Contacts {
|
||||
const result = await ContactService.deleteField(auth.projectId!, field);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to delete field:', error);
|
||||
signale.error('[CONTACTS] Failed to delete field:', error);
|
||||
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete field',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import signale from 'signale';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth} from '../middleware/auth.js';
|
||||
@@ -118,7 +119,7 @@ export class Events {
|
||||
const usage = await EventService.getEventUsage(auth.projectId!, eventName);
|
||||
return res.status(200).json(usage);
|
||||
} catch (error) {
|
||||
console.error('[EVENTS] Failed to get event usage:', error);
|
||||
signale.error('[EVENTS] Failed to get event usage:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get event usage',
|
||||
});
|
||||
@@ -145,7 +146,7 @@ export class Events {
|
||||
const result = await EventService.deleteEvent(auth.projectId!, eventName);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[EVENTS] Failed to delete event:', error);
|
||||
signale.error('[EVENTS] Failed to delete event:', error);
|
||||
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete event',
|
||||
});
|
||||
|
||||
@@ -40,6 +40,10 @@ export class Github {
|
||||
}
|
||||
const {code} = req.query;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
|
||||
}
|
||||
|
||||
const data = new URLSearchParams({
|
||||
client_id: GITHUB_OAUTH_CLIENT,
|
||||
client_secret: GITHUB_OAUTH_SECRET,
|
||||
@@ -47,19 +51,33 @@ export class Github {
|
||||
redirect_uri: `${API_URI}/oauth/github/callback`,
|
||||
});
|
||||
|
||||
const {access_token, token_type} = await fetch('https://github.com/login/oauth/access_token', {
|
||||
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {'Content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'},
|
||||
body: data,
|
||||
}).then(res => res.json());
|
||||
|
||||
if (!tokenResponse.access_token || !tokenResponse.token_type) {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to authenticate with GitHub');
|
||||
}
|
||||
|
||||
const emails = await fetch(`https://api.github.com/user/emails`, {
|
||||
headers: {Authorization: `${token_type} ${access_token}`},
|
||||
headers: {Authorization: `${tokenResponse.token_type} ${tokenResponse.access_token}`},
|
||||
}).then(res => res.json());
|
||||
|
||||
const email = emails.find((e: {primary: boolean; email: string}) => e.primary).email;
|
||||
if (!Array.isArray(emails) || emails.length === 0) {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve emails from GitHub');
|
||||
}
|
||||
|
||||
let user = await UserService.email(email as string);
|
||||
const primaryEmail = emails.find((e: {primary: boolean; email: string}) => e.primary);
|
||||
|
||||
if (!primaryEmail || !primaryEmail.email || typeof primaryEmail.email !== 'string') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve primary email from GitHub');
|
||||
}
|
||||
|
||||
const email = primaryEmail.email;
|
||||
|
||||
let user = await UserService.email(email);
|
||||
let isNewUser = false;
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -35,6 +35,10 @@ export class Google {
|
||||
}
|
||||
const {code} = req.query;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
|
||||
}
|
||||
|
||||
const data = new URLSearchParams({
|
||||
client_id: GOOGLE_OAUTH_CLIENT,
|
||||
client_secret: GOOGLE_OAUTH_SECRET,
|
||||
@@ -43,15 +47,25 @@ export class Google {
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
|
||||
const {access_token} = await fetch('https://oauth2.googleapis.com/token', {
|
||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: {'Content-type': 'application/x-www-form-urlencoded'},
|
||||
body: data,
|
||||
}).then(res => res.json());
|
||||
|
||||
const {email} = await fetch(`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${access_token}`).then(
|
||||
res => res.json(),
|
||||
);
|
||||
if (!tokenResponse.access_token) {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to authenticate with Google');
|
||||
}
|
||||
|
||||
const userInfoResponse = await fetch(
|
||||
`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${tokenResponse.access_token}`,
|
||||
).then(res => res.json());
|
||||
|
||||
if (!userInfoResponse.email || typeof userInfoResponse.email !== 'string') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve email from Google');
|
||||
}
|
||||
|
||||
const email = userInfoResponse.email;
|
||||
|
||||
let user = await UserService.email(email);
|
||||
let isNewUser = false;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import {MembershipSchemas} from '@plunk/shared';
|
||||
import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth} from '../middleware/auth.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('projects')
|
||||
@@ -19,7 +20,7 @@ export class Projects {
|
||||
@CatchAsync
|
||||
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
||||
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
|
||||
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 /projects/:id/members
|
||||
@@ -92,7 +125,7 @@ export class Projects {
|
||||
@CatchAsync
|
||||
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
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
|
||||
const membership = await prisma.membership.findFirst({
|
||||
@@ -141,7 +174,7 @@ export class Projects {
|
||||
@CatchAsync
|
||||
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Validate params
|
||||
if (!id) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Controller, Middleware, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import multer from 'multer';
|
||||
import signale from 'signale';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth} from '../middleware/auth.js';
|
||||
@@ -66,7 +67,7 @@ export class Uploads {
|
||||
size: req.file.size,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[UPLOADS] Failed to upload image:', error);
|
||||
signale.error('[UPLOADS] Failed to upload image:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to upload image',
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {randomBytes} from 'node:crypto';
|
||||
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
|
||||
import {BillingLimitSchemas, ProjectSchemas} from '@plunk/shared';
|
||||
import {BillingLimitSchemas, ProjectSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {NotAuthenticated, NotFound} from '../exceptions/index.js';
|
||||
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {isAuthenticated} from '../middleware/auth.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
@@ -63,6 +63,16 @@ export class Users {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
// Check if user is a member of any disabled project
|
||||
const {hasDisabledProject, disabledProjectNames} = await SecurityService.userHasDisabledProject(auth.userId);
|
||||
if (hasDisabledProject) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
`You cannot create new projects while you are a member of disabled projects: ${disabledProjectNames.join(', ')}. Please contact support to resolve security violations.`,
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
const {name} = ProjectSchemas.create.parse(req.body);
|
||||
|
||||
// Generate unique API keys
|
||||
@@ -95,7 +105,7 @@ export class Users {
|
||||
@CatchAsync
|
||||
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
||||
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);
|
||||
|
||||
// Verify user has access to this project
|
||||
@@ -127,7 +137,7 @@ export class Users {
|
||||
@CatchAsync
|
||||
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
||||
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
|
||||
const membership = await prisma.membership.findFirst({
|
||||
@@ -179,7 +189,7 @@ export class Users {
|
||||
@CatchAsync
|
||||
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
@@ -258,7 +268,7 @@ export class Users {
|
||||
@CatchAsync
|
||||
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
@@ -308,7 +318,7 @@ export class Users {
|
||||
@CatchAsync
|
||||
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -341,7 +351,7 @@ export class Users {
|
||||
@CatchAsync
|
||||
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
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');
|
||||
}
|
||||
|
||||
// Get the project
|
||||
// Get the project with current limits
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
select: {
|
||||
subscription: true,
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -395,8 +408,17 @@ export class Users {
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cache so new limits take effect immediately
|
||||
await BillingLimitService.invalidateCache(id);
|
||||
// Clear notification cache keys for limits that changed
|
||||
// This allows new warning/limit emails to be sent when the new limits are reached
|
||||
await BillingLimitService.clearNotificationCacheForChangedLimits(
|
||||
id,
|
||||
{
|
||||
workflows: project.billingLimitWorkflows,
|
||||
campaigns: project.billingLimitCampaigns,
|
||||
transactional: project.billingLimitTransactional,
|
||||
},
|
||||
data,
|
||||
);
|
||||
|
||||
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
||||
|
||||
@@ -408,7 +430,7 @@ export class Users {
|
||||
@CatchAsync
|
||||
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
@@ -534,7 +556,7 @@ export class Users {
|
||||
@CatchAsync
|
||||
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
@@ -622,7 +644,7 @@ export class Users {
|
||||
@CatchAsync
|
||||
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -655,7 +677,7 @@ export class Users {
|
||||
@CatchAsync
|
||||
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
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');
|
||||
}
|
||||
|
||||
// Check if project is disabled - block reset operation
|
||||
const isDisabled = await SecurityService.isProjectDisabled(id);
|
||||
if (isDisabled) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Cannot reset a disabled project. Please contact support to resolve security violations before making changes.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all project data in a transaction
|
||||
await prisma.$transaction(async tx => {
|
||||
// Delete all emails
|
||||
@@ -731,7 +763,7 @@ export class Users {
|
||||
@CatchAsync
|
||||
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
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({
|
||||
where: {id},
|
||||
select: {
|
||||
name: true,
|
||||
subscription: true,
|
||||
customer: true,
|
||||
disabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -772,6 +805,15 @@ export class Users {
|
||||
throw new NotFound('Project not found');
|
||||
}
|
||||
|
||||
// Check if project is disabled - block delete operation
|
||||
if (project.disabled) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Cannot delete a disabled project. Please contact support to resolve security violations.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
// If project has an active subscription, cancel it first
|
||||
if (STRIPE_ENABLED && stripe && project.subscription) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import {WorkflowExecutionStatus} from '@plunk/db';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import signale from 'signale';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth} from '../middleware/auth.js';
|
||||
@@ -45,7 +46,7 @@ export class Workflows {
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[WORKFLOWS] Failed to get available fields:', error);
|
||||
signale.error('[WORKFLOWS] Failed to get available fields:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
|
||||
@@ -14,11 +15,11 @@ export function createCampaignWorker() {
|
||||
async (job: Job<CampaignBatchJobData>) => {
|
||||
const {campaignId, batchNumber, offset, limit, cursor} = job.data;
|
||||
|
||||
console.log(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
|
||||
signale.info(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
|
||||
|
||||
await CampaignService.processBatch(campaignId, batchNumber, offset, limit, cursor);
|
||||
|
||||
console.log(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
|
||||
signale.info(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
|
||||
},
|
||||
{
|
||||
connection: campaignQueue.opts.connection,
|
||||
@@ -27,15 +28,15 @@ export function createCampaignWorker() {
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
|
||||
signale.info(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
signale.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
|
||||
signale.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
|
||||
@@ -3,18 +3,50 @@
|
||||
* 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 signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {EmailService} from '../services/EmailService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
|
||||
import {sendRawEmail} from '../services/SESService.js';
|
||||
import {DASHBOARD_URI} from '../app/constants.js';
|
||||
import {getSendingQuota, sendRawEmail} from '../services/SESService.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>(
|
||||
emailQueue.name,
|
||||
async (job: Job<SendEmailJobData>) => {
|
||||
@@ -38,7 +70,7 @@ export function createEmailWorker() {
|
||||
|
||||
// Check if project is disabled
|
||||
if (email.project.disabled) {
|
||||
console.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
|
||||
signale.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
|
||||
await prisma.email.update({
|
||||
where: {id: emailId},
|
||||
data: {
|
||||
@@ -138,8 +170,59 @@ export function createEmailWorker() {
|
||||
sourceType: email.sourceType,
|
||||
sentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// If this email belongs to a campaign, check if all campaign emails have been sent
|
||||
if (email.campaignId) {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: email.campaignId},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
totalRecipients: true,
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Only check if campaign is still in SENDING status
|
||||
if (campaign && campaign.status === CampaignStatus.SENDING) {
|
||||
// Count how many emails have been sent for this campaign
|
||||
const sentCount = await prisma.email.count({
|
||||
where: {
|
||||
campaignId: email.campaignId,
|
||||
sentAt: {not: null},
|
||||
},
|
||||
});
|
||||
|
||||
// If all emails have been sent, mark campaign as SENT
|
||||
if (sentCount >= campaign.totalRecipients) {
|
||||
await prisma.campaign.update({
|
||||
where: {id: email.campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.SENT,
|
||||
},
|
||||
});
|
||||
|
||||
signale.success(
|
||||
`[EMAIL-PROCESSOR] Campaign ${campaign.name} completed: ${sentCount}/${campaign.totalRecipients} emails sent`,
|
||||
);
|
||||
|
||||
// Send notification about campaign send completed
|
||||
const {NtfyService} = await import('../services/NtfyService.js');
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
campaign.name,
|
||||
campaign.project.name,
|
||||
campaign.projectId,
|
||||
campaign.totalRecipients,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
|
||||
signale.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
|
||||
|
||||
// Mark as failed
|
||||
await prisma.email.update({
|
||||
@@ -157,22 +240,22 @@ export function createEmailWorker() {
|
||||
connection: emailQueue.opts.connection,
|
||||
concurrency: 10, // Process up to 10 emails concurrently
|
||||
limiter: {
|
||||
max: 25, // Max 25 emails per second
|
||||
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
|
||||
duration: 1000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
|
||||
signale.info(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
signale.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[EMAIL-PROCESSOR] Worker error:', err);
|
||||
signale.error('[EMAIL-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import {parse} from 'csv-parse/sync';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
@@ -28,7 +29,7 @@ export function createImportWorker() {
|
||||
async (job: Job<ContactImportJobData>) => {
|
||||
const {projectId, csvData, filename} = job.data;
|
||||
|
||||
console.log(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
|
||||
signale.info(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
|
||||
|
||||
// Fetch project information for notifications
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -66,7 +67,7 @@ export function createImportWorker() {
|
||||
throw new Error('CSV file is empty');
|
||||
}
|
||||
|
||||
console.log(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
|
||||
signale.info(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
|
||||
|
||||
// Notify that import has started
|
||||
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
|
||||
@@ -151,7 +152,7 @@ export function createImportWorker() {
|
||||
await job.updateProgress(progress);
|
||||
}
|
||||
|
||||
console.log(
|
||||
signale.info(
|
||||
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
|
||||
);
|
||||
|
||||
@@ -168,7 +169,7 @@ export function createImportWorker() {
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
|
||||
signale.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
|
||||
|
||||
// Notify that import has failed
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@@ -191,15 +192,15 @@ export function createImportWorker() {
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
|
||||
signale.info(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
signale.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[IMPORT-PROCESSOR] Worker error:', err);
|
||||
signale.error('[IMPORT-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
@@ -16,7 +17,7 @@ export function createScheduledCampaignWorker() {
|
||||
async (job: Job<ScheduledCampaignJobData>) => {
|
||||
const {campaignId} = job.data;
|
||||
|
||||
console.log(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
|
||||
signale.info(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
|
||||
|
||||
// Get campaign with project
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
@@ -29,13 +30,13 @@ export function createScheduledCampaignWorker() {
|
||||
});
|
||||
|
||||
if (!campaign) {
|
||||
console.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
|
||||
signale.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if project is disabled
|
||||
if (campaign.project.disabled) {
|
||||
console.warn(
|
||||
signale.warn(
|
||||
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
|
||||
);
|
||||
await prisma.campaign.update({
|
||||
@@ -47,7 +48,7 @@ export function createScheduledCampaignWorker() {
|
||||
|
||||
// Verify campaign is still in SCHEDULED status
|
||||
if (campaign.status !== CampaignStatus.SCHEDULED) {
|
||||
console.warn(
|
||||
signale.warn(
|
||||
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
|
||||
);
|
||||
return;
|
||||
@@ -56,7 +57,7 @@ export function createScheduledCampaignWorker() {
|
||||
// Start sending the campaign
|
||||
await CampaignService.startSending(campaign.projectId, campaignId);
|
||||
|
||||
console.log(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
|
||||
signale.info(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
|
||||
},
|
||||
{
|
||||
connection: scheduledQueue.opts.connection,
|
||||
@@ -65,15 +66,15 @@ export function createScheduledCampaignWorker() {
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
|
||||
signale.info(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
signale.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[SCHEDULED-PROCESSOR] Worker error:', err);
|
||||
signale.error('[SCHEDULED-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
|
||||
@@ -25,7 +25,7 @@ async function startWorkers() {
|
||||
|
||||
try {
|
||||
// Start email worker
|
||||
const emailWorker = createEmailWorker();
|
||||
const emailWorker = await createEmailWorker();
|
||||
workers.push({name: 'email', worker: emailWorker});
|
||||
signale.success('[WORKER] Email worker started');
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
|
||||
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
|
||||
@@ -33,15 +34,15 @@ export function createWorkflowWorker() {
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
console.log(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
|
||||
signale.info(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
signale.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
console.error('[WORKFLOW-PROCESSOR] Worker error:', err);
|
||||
signale.error('[WORKFLOW-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
|
||||
@@ -65,7 +65,7 @@ export const jwt = {
|
||||
* @param request The express request object
|
||||
*/
|
||||
export function parseJwt(request: Request): string {
|
||||
const token: string | undefined = request.cookies.token;
|
||||
const token: string | undefined = request.cookies.next_token;
|
||||
|
||||
if (!token) {
|
||||
throw new NotAuthenticated();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {Prisma} from '@plunk/db';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
/**
|
||||
* Activity types that can be tracked
|
||||
@@ -170,7 +172,7 @@ export class ActivityService {
|
||||
*/
|
||||
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
|
||||
// Try to get from cache
|
||||
const cacheKey = `activity:stats:${projectId}:${startDate?.getTime() || 'all'}:${endDate?.getTime() || 'now'}`;
|
||||
const cacheKey = Keys.Activity.stats(projectId, startDate?.getTime() || 'all', endDate?.getTime() || 'now');
|
||||
|
||||
try {
|
||||
const cached = await redis.get(cacheKey);
|
||||
@@ -178,7 +180,7 @@ export class ActivityService {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ACTIVITY] Failed to get stats from cache:', error);
|
||||
signale.warn('[ACTIVITY] Failed to get stats from cache:', error);
|
||||
}
|
||||
|
||||
// Default date range to last 30 days if not specified
|
||||
@@ -241,7 +243,7 @@ export class ActivityService {
|
||||
try {
|
||||
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
|
||||
} catch (error) {
|
||||
console.warn('[ACTIVITY] Failed to cache stats:', error);
|
||||
signale.warn('[ACTIVITY] Failed to cache stats:', error);
|
||||
}
|
||||
|
||||
return stats;
|
||||
@@ -261,7 +263,7 @@ export class ActivityService {
|
||||
await redis.del(...keys);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
|
||||
signale.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
/**
|
||||
* Time series data point for analytics
|
||||
@@ -57,7 +58,7 @@ export class AnalyticsService {
|
||||
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `analytics:timeseries:${projectId}:${limitedStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
|
||||
const cacheKey = Keys.Analytics.timeseries(projectId, limitedStartDate.toISOString(), effectiveEndDate.toISOString());
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
@@ -190,7 +191,7 @@ export class AnalyticsService {
|
||||
const effectiveEndDate = endDate || now;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = `analytics:campaignStats:${projectId}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
|
||||
const cacheKey = Keys.Analytics.campaignStats(projectId, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
@@ -294,7 +295,7 @@ export class AnalyticsService {
|
||||
const effectiveEndDate = endDate || now;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = `analytics:topEvents:${projectId}:${limit}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
|
||||
const cacheKey = Keys.Analytics.topEvents(projectId, limit, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {EmailSourceType} from '@plunk/db';
|
||||
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
|
||||
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
|
||||
/**
|
||||
@@ -221,7 +225,8 @@ export class BillingLimitService {
|
||||
project.billingLimitTransactional !== null;
|
||||
|
||||
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
||||
if (!project.subscription && !hasCustomLimits) {
|
||||
// Only enforce free tier limits if billing is enabled
|
||||
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||
const totalUsage = await this.getTotalUsage(projectId);
|
||||
const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
|
||||
const percentage = (totalUsage / freeLimit) * 100;
|
||||
@@ -236,6 +241,9 @@ export class BillingLimitService {
|
||||
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
||||
);
|
||||
|
||||
// Send email notification
|
||||
await this.sendLimitExceededEmail(projectId, project.name, totalUsage, freeLimit, 'Free Tier (All Types)');
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
warning: false,
|
||||
@@ -257,6 +265,16 @@ export class BillingLimitService {
|
||||
percentage,
|
||||
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
||||
);
|
||||
|
||||
// Send email notification (only once per month)
|
||||
await this.sendWarningEmail(
|
||||
projectId,
|
||||
project.name,
|
||||
totalUsage,
|
||||
freeLimit,
|
||||
percentage,
|
||||
'Free Tier (All Types)',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -297,6 +315,9 @@ export class BillingLimitService {
|
||||
if (project) {
|
||||
// Send notification about limit exceeded
|
||||
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
|
||||
|
||||
// Send email notification
|
||||
await this.sendLimitExceededEmail(projectId, project.name, usage, limit, sourceType);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -320,7 +341,17 @@ export class BillingLimitService {
|
||||
});
|
||||
|
||||
if (project) {
|
||||
await NtfyService.notifyBillingLimitApproaching(project.name, projectId, usage, limit, percentage, sourceType);
|
||||
await NtfyService.notifyBillingLimitApproaching(
|
||||
project.name,
|
||||
projectId,
|
||||
usage,
|
||||
limit,
|
||||
percentage,
|
||||
sourceType,
|
||||
);
|
||||
|
||||
// Send email notification (only once per month)
|
||||
await this.sendWarningEmail(projectId, project.name, usage, limit, percentage, sourceType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +446,8 @@ export class BillingLimitService {
|
||||
project.billingLimitTransactional !== null;
|
||||
|
||||
// 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 limit = this.FREE_TIER_TOTAL_LIMIT;
|
||||
const percentage = (totalUsage / limit) * 100;
|
||||
@@ -455,7 +487,11 @@ export class BillingLimitService {
|
||||
|
||||
/**
|
||||
* Invalidate usage cache for a project
|
||||
* Call this when billing period resets or limits are changed
|
||||
* Primarily used in tests to reset cache state between scenarios.
|
||||
* In production, the cache naturally expires after 5 minutes.
|
||||
*
|
||||
* NOTE: Updating billing limits does NOT require clearing usage cache
|
||||
* (use clearNotificationCacheForChangedLimits instead)
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
*/
|
||||
@@ -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
|
||||
*/
|
||||
@@ -481,7 +580,7 @@ export class BillingLimitService {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
return `billing:usage:${projectId}:${sourceType}:${year}-${month}`;
|
||||
return Keys.Billing.usage(projectId, sourceType, year, month);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -493,4 +592,110 @@ export class BillingLimitService {
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
return {start, end};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send billing limit warning email to project members
|
||||
*/
|
||||
private static async sendWarningEmail(
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
usage: number,
|
||||
limit: number,
|
||||
percentage: number,
|
||||
sourceType: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if we've already sent this warning email this month
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
|
||||
|
||||
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 {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
|
||||
import type {FilterCondition} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
||||
|
||||
import {BillingLimitService} from './BillingLimitService.js';
|
||||
import {DomainService} from './DomainService.js';
|
||||
import {EmailService} from './EmailService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {SegmentService} from './SegmentService.js';
|
||||
import {DASHBOARD_URI} from '../app/constants.js';
|
||||
import {DASHBOARD_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||
import {sendRawEmail} from './SESService.js';
|
||||
|
||||
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
|
||||
@@ -22,8 +24,8 @@ export interface CreateCampaignData {
|
||||
subject: string;
|
||||
body: string;
|
||||
from: string;
|
||||
fromName?: string;
|
||||
replyTo?: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
audienceType: CampaignAudienceType;
|
||||
audienceCondition?: FilterCondition;
|
||||
segmentId?: string;
|
||||
@@ -35,8 +37,8 @@ export interface UpdateCampaignData {
|
||||
subject?: string;
|
||||
body?: string;
|
||||
from?: string;
|
||||
fromName?: string;
|
||||
replyTo?: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
audienceType?: CampaignAudienceType;
|
||||
audienceCondition?: FilterCondition;
|
||||
segmentId?: string;
|
||||
@@ -71,7 +73,7 @@ export class CampaignService {
|
||||
SegmentService.validateCondition(data.audienceCondition);
|
||||
}
|
||||
|
||||
// Create campaign
|
||||
// Create campaign with initial recipient count of 0
|
||||
const campaign = await prisma.campaign.create({
|
||||
data: {
|
||||
projectId,
|
||||
@@ -86,6 +88,7 @@ export class CampaignService {
|
||||
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
|
||||
segmentId: data.segmentId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
totalRecipients: 0, // Will be updated below
|
||||
},
|
||||
include: {
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
return prisma.campaign.update({
|
||||
// Update the campaign first
|
||||
const updatedCampaign = await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// If audience-related fields changed and campaign is still a draft, recalculate totalRecipients
|
||||
const audienceChanged =
|
||||
data.audienceType !== undefined || data.segmentId !== undefined || data.audienceCondition !== undefined;
|
||||
|
||||
if (audienceChanged && updatedCampaign.status === CampaignStatus.DRAFT) {
|
||||
const recipientCount = await this.getRecipientCount(projectId, updatedCampaign);
|
||||
return prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {totalRecipients: recipientCount},
|
||||
});
|
||||
}
|
||||
|
||||
return updatedCampaign;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +277,7 @@ export class CampaignService {
|
||||
const campaign = await this.get(projectId, campaignId);
|
||||
|
||||
// Create a new campaign with the same data but reset status and stats
|
||||
return prisma.campaign.create({
|
||||
const duplicatedCampaign = await prisma.campaign.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: `${campaign.name} (Copy)`,
|
||||
@@ -266,7 +291,7 @@ export class CampaignService {
|
||||
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
||||
segmentId: campaign.segmentId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
totalRecipients: 0,
|
||||
totalRecipients: 0, // Will be updated below
|
||||
sentCount: 0,
|
||||
deliveredCount: 0,
|
||||
openedCount: 0,
|
||||
@@ -274,6 +299,13 @@ export class CampaignService {
|
||||
bouncedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate and update recipient count
|
||||
const recipientCount = await this.getRecipientCount(projectId, duplicatedCampaign);
|
||||
return prisma.campaign.update({
|
||||
where: {id: duplicatedCampaign.id},
|
||||
data: {totalRecipients: recipientCount},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,6 +326,24 @@ export class CampaignService {
|
||||
throw new HttpException(400, 'Campaign has no recipients');
|
||||
}
|
||||
|
||||
// Check billing limits before scheduling/sending the campaign
|
||||
// This ensures users cannot schedule campaigns that would exceed their quota
|
||||
if (STRIPE_ENABLED) {
|
||||
const limitCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||
|
||||
// If there's a limit set, verify the campaign won't exceed it
|
||||
if (limitCheck.limit !== null) {
|
||||
const projectedUsage = limitCheck.usage + recipientCount;
|
||||
|
||||
if (projectedUsage > limitCheck.limit) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
`Cannot ${scheduledFor ? 'schedule' : 'send'} campaign: would exceed billing limit. Current usage: ${limitCheck.usage}/${limitCheck.limit} emails, campaign recipients: ${recipientCount}. Upgrade your plan or reduce campaign recipients.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduledFor) {
|
||||
// Schedule for later
|
||||
if (scheduledFor.getTime() <= Date.now()) {
|
||||
@@ -412,7 +462,7 @@ export class CampaignService {
|
||||
}
|
||||
|
||||
if (campaign.status !== CampaignStatus.SENDING) {
|
||||
console.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
|
||||
signale.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -458,21 +508,11 @@ export class CampaignService {
|
||||
replyTo: campaign.replyTo || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
||||
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
||||
// Continue with other contacts even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
// Update sent count
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {
|
||||
sentCount: {
|
||||
increment: contacts.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Queue next batch if there are more contacts
|
||||
if (hasMore && nextCursor) {
|
||||
await QueueService.queueCampaignBatch({
|
||||
@@ -482,27 +522,6 @@ export class CampaignService {
|
||||
limit,
|
||||
cursor: nextCursor,
|
||||
});
|
||||
} else {
|
||||
// All batches processed, mark campaign as SENT
|
||||
const completedCampaign = await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.SENT,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification about campaign send completed
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
completedCampaign.name,
|
||||
completedCampaign.project.name,
|
||||
completedCampaign.projectId,
|
||||
completedCampaign.totalRecipients || 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -407,7 +407,7 @@ export class EmailService {
|
||||
sentAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[EMAIL] Failed to send email ${emailId}:`, error);
|
||||
signale.error(`[EMAIL] Failed to send email ${emailId}:`, error);
|
||||
|
||||
// Mark as failed
|
||||
await prisma.email.update({
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type {Event} from '@plunk/db';
|
||||
import {Prisma} from '@plunk/db';
|
||||
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {ValidationError} from '../exceptions/index.js';
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
||||
|
||||
@@ -48,11 +51,11 @@ export class EventService {
|
||||
* Should be called when workflows are enabled/disabled or updated
|
||||
*/
|
||||
public static async invalidateWorkflowCache(projectId: string): Promise<void> {
|
||||
const cacheKey = `workflows:enabled:${projectId}`;
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
try {
|
||||
await redis.del(cacheKey);
|
||||
} catch (error) {
|
||||
console.warn('[EVENT] Failed to invalidate workflow cache:', error);
|
||||
signale.warn('[EVENT] Failed to invalidate workflow cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,9 +290,9 @@ export class EventService {
|
||||
* @param eventName - The event name to delete
|
||||
*/
|
||||
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
|
||||
// Prevent deletion of system events
|
||||
if (eventName.startsWith('email.') || eventName.startsWith('segment.')) {
|
||||
throw new Error('Cannot delete system events (email.* or segment.*)');
|
||||
// Prevent deletion of reserved system events
|
||||
if (this.isReservedEvent(eventName)) {
|
||||
throw new Error(`Cannot delete reserved system event: ${eventName}`);
|
||||
}
|
||||
|
||||
// Check if event is in use
|
||||
@@ -311,6 +314,36 @@ export class EventService {
|
||||
return {deletedCount: result.count};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event name is reserved for system use
|
||||
* Reserved patterns:
|
||||
* - email.* (email.sent, email.delivery, email.open, email.click, email.bounce, email.complaint)
|
||||
* - contact.subscribed, contact.unsubscribed
|
||||
* - segment.*.entry, segment.*.exit
|
||||
*
|
||||
* @param eventName - The event name to check
|
||||
* @returns true if the event is reserved, false otherwise
|
||||
*/
|
||||
public static isReservedEvent(eventName: string): boolean {
|
||||
// Email events: email.*
|
||||
if (eventName.startsWith('email.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Contact events: contact.subscribed, contact.unsubscribed
|
||||
if (eventName === 'contact.subscribed' || eventName === 'contact.unsubscribed') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Segment events: segment.*.entry, segment.*.exit
|
||||
// Pattern: segment.<slug>.entry or segment.<slug>.exit
|
||||
if (eventName.startsWith('segment.') && (eventName.endsWith('.entry') || eventName.endsWith('.exit'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger workflows based on an event
|
||||
* Uses Redis caching for enabled workflows to improve performance
|
||||
@@ -322,7 +355,7 @@ export class EventService {
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
// Try to get workflows from cache
|
||||
const cacheKey = `workflows:enabled:${projectId}`;
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
let workflows;
|
||||
|
||||
try {
|
||||
@@ -331,7 +364,7 @@ export class EventService {
|
||||
workflows = JSON.parse(cached);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[EVENT] Failed to get workflows from cache:', error);
|
||||
signale.warn('[EVENT] Failed to get workflows from cache:', error);
|
||||
}
|
||||
|
||||
// If not in cache, fetch from database
|
||||
@@ -353,7 +386,7 @@ export class EventService {
|
||||
try {
|
||||
await redis.setex(cacheKey, 300, JSON.stringify(workflows));
|
||||
} catch (error) {
|
||||
console.warn('[EVENT] Failed to cache workflows:', error);
|
||||
signale.warn('[EVENT] Failed to cache workflows:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +401,7 @@ export class EventService {
|
||||
} else {
|
||||
// If event is not contact-specific, you might want different logic
|
||||
// For example, trigger for all contacts, or skip
|
||||
console.log(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
|
||||
signale.info(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,7 +427,7 @@ export class EventService {
|
||||
});
|
||||
|
||||
if (!workflow || workflow.steps.length === 0) {
|
||||
console.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
|
||||
signale.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -429,7 +462,7 @@ export class EventService {
|
||||
const triggerStep = workflow.steps[0];
|
||||
|
||||
if (!triggerStep) {
|
||||
console.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
|
||||
signale.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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)' : ''}`,
|
||||
);
|
||||
|
||||
// Start executing the workflow
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
} catch (error) {
|
||||
console.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
|
||||
signale.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
PutBucketPolicyCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import crypto from 'crypto';
|
||||
import signale from 'signale';
|
||||
|
||||
import {
|
||||
S3_ENDPOINT,
|
||||
S3_ACCESS_KEY_ID,
|
||||
@@ -69,13 +71,13 @@ export async function initializeBucket(): Promise<void> {
|
||||
Bucket: S3_BUCKET,
|
||||
}),
|
||||
);
|
||||
console.log(`[S3] Created bucket: ${S3_BUCKET}`);
|
||||
signale.info(`[S3] Created bucket: ${S3_BUCKET}`);
|
||||
} catch (createError) {
|
||||
console.error('[S3] Failed to create bucket:', createError);
|
||||
signale.error('[S3] Failed to create bucket:', createError);
|
||||
throw createError;
|
||||
}
|
||||
} else {
|
||||
console.error('[S3] Failed to check bucket:', error);
|
||||
signale.error('[S3] Failed to check bucket:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -103,10 +105,10 @@ export async function initializeBucket(): Promise<void> {
|
||||
);
|
||||
|
||||
if (!bucketExists) {
|
||||
console.log(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
|
||||
signale.info(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
|
||||
}
|
||||
} catch (policyError) {
|
||||
console.error('[S3] Failed to set bucket policy:', policyError);
|
||||
signale.error('[S3] Failed to set bucket policy:', policyError);
|
||||
// Don't throw - bucket was created but policy failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {SES} from '@aws-sdk/client-ses';
|
||||
import signale from 'signale';
|
||||
|
||||
import {
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
@@ -6,7 +7,8 @@ import {
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
DASHBOARD_URI,
|
||||
SES_CONFIGURATION_SET,
|
||||
SES_CONFIGURATION_SET_NO_TRACKING
|
||||
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||
TRACKING_TOGGLE_ENABLED
|
||||
} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
@@ -160,10 +162,15 @@ ${breakLongLines(attachment.content, 76, true)}`,
|
||||
: ''
|
||||
}${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
|
||||
const response = await ses.sendRawEmail({
|
||||
Destinations: destinations,
|
||||
ConfigurationSetName: tracking ? SES_CONFIGURATION_SET : SES_CONFIGURATION_SET_NO_TRACKING,
|
||||
ConfigurationSetName: configurationSetName,
|
||||
RawMessage: {
|
||||
Data: new TextEncoder().encode(rawMessage),
|
||||
},
|
||||
@@ -250,3 +257,26 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
|
||||
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 {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
* Security thresholds for bounce and complaint rates
|
||||
@@ -45,7 +49,6 @@ interface SecurityStatus {
|
||||
}
|
||||
|
||||
export class SecurityService {
|
||||
private static readonly CACHE_PREFIX = 'security';
|
||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
@@ -54,7 +57,7 @@ export class SecurityService {
|
||||
public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> {
|
||||
try {
|
||||
// Try to get from cache first
|
||||
const cacheKey = this.getCacheKey(projectId, 'rates');
|
||||
const cacheKey = Keys.Security.rates(projectId);
|
||||
const cached = await redis.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
@@ -107,9 +110,31 @@ export class SecurityService {
|
||||
// Get current security status
|
||||
const status = await this.getSecurityStatus(projectId);
|
||||
|
||||
// If project should be disabled, disable it
|
||||
if (status.shouldDisable) {
|
||||
// If project should be disabled, disable it (only if auto-disable is enabled)
|
||||
if (status.shouldDisable && AUTO_PROJECT_DISABLE) {
|
||||
await this.disableProject(projectId, status);
|
||||
} else if (status.shouldDisable && !AUTO_PROJECT_DISABLE) {
|
||||
// Log critical violations but don't auto-disable (self-hosted mode)
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {name: true},
|
||||
});
|
||||
|
||||
if (project) {
|
||||
signale.error(
|
||||
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
|
||||
status.violations,
|
||||
);
|
||||
signale.info(
|
||||
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
|
||||
);
|
||||
signale.info(
|
||||
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
|
||||
);
|
||||
|
||||
// Send notification about critical security violations
|
||||
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
|
||||
}
|
||||
} else if (status.warnings.length > 0) {
|
||||
// Log warnings for monitoring
|
||||
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
|
||||
@@ -137,13 +162,55 @@ export class SecurityService {
|
||||
*/
|
||||
public static async invalidateCache(projectId: string): Promise<void> {
|
||||
try {
|
||||
const cacheKey = this.getCacheKey(projectId, 'rates');
|
||||
const cacheKey = Keys.Security.rates(projectId);
|
||||
await redis.del(cacheKey);
|
||||
} catch (error) {
|
||||
signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a member of any disabled project
|
||||
* Users with disabled projects cannot create new projects
|
||||
*/
|
||||
public static async userHasDisabledProject(userId: string): Promise<{
|
||||
hasDisabledProject: boolean;
|
||||
disabledProjectNames: string[];
|
||||
}> {
|
||||
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)
|
||||
*/
|
||||
@@ -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
|
||||
*/
|
||||
@@ -345,6 +405,29 @@ export class SecurityService {
|
||||
|
||||
// Send urgent notification about project suspension
|
||||
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
|
||||
|
||||
// Send email notification to project members
|
||||
try {
|
||||
const members = await 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) {
|
||||
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
||||
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
@@ -276,7 +277,7 @@ export class SegmentService {
|
||||
data: {memberCount},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to update count for segment ${segment.id}:`, error);
|
||||
signale.error(`Failed to update count for segment ${segment.id}:`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -383,7 +384,7 @@ export class SegmentService {
|
||||
segmentName: segment.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[SEGMENT] Failed to track segment entry event for contact ${contactId}:`, error);
|
||||
signale.error(`[SEGMENT] Failed to track segment entry event for contact ${contactId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,7 +415,7 @@ export class SegmentService {
|
||||
segmentName: segment.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[SEGMENT] Failed to track segment exit event for contact ${contactId}:`, error);
|
||||
signale.error(`[SEGMENT] Failed to track segment exit event for contact ${contactId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,7 +426,7 @@ export class SegmentService {
|
||||
data: {memberCount: matchingContactIds.size},
|
||||
});
|
||||
|
||||
console.log(
|
||||
signale.info(
|
||||
`[SEGMENT] Computed membership for segment ${segmentId}: added ${toAdd.length}, removed ${toRemove.length}, total ${matchingContactIds.size}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -88,8 +88,8 @@ export class TemplateService {
|
||||
subject: string;
|
||||
body: string;
|
||||
from: string;
|
||||
fromName?: string;
|
||||
replyTo?: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
type?: Template['type'];
|
||||
},
|
||||
): Promise<Template> {
|
||||
@@ -120,8 +120,8 @@ export class TemplateService {
|
||||
subject?: string;
|
||||
body?: string;
|
||||
from?: string;
|
||||
fromName?: string;
|
||||
replyTo?: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
type?: Template['type'];
|
||||
},
|
||||
): Promise<Template> {
|
||||
|
||||
@@ -43,7 +43,7 @@ function getCookieDomain(): string | undefined {
|
||||
}
|
||||
|
||||
export class UserService {
|
||||
public static readonly COOKIE_NAME = 'token';
|
||||
public static readonly COOKIE_NAME = 'next_token';
|
||||
|
||||
public static async id(id: string) {
|
||||
return wrapRedis(Keys.User.id(id), async () => {
|
||||
@@ -52,6 +52,10 @@ export class UserService {
|
||||
}
|
||||
|
||||
public static async email(email: string) {
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return wrapRedis(Keys.User.email(email), async () => {
|
||||
return prisma.user.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
|
||||
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
@@ -786,7 +787,7 @@ export class WorkflowService {
|
||||
// Start executing the workflow asynchronously
|
||||
// Don't await - let it run in background
|
||||
WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => {
|
||||
console.error('Error executing workflow:', error);
|
||||
signale.error('Error executing workflow:', error);
|
||||
});
|
||||
|
||||
return execution;
|
||||
|
||||
@@ -5,6 +5,28 @@ import {EmailService} from '../EmailService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {redis} from '../../database/redis';
|
||||
|
||||
// Mock STRIPE_ENABLED and STRIPE_SK for free tier tests
|
||||
vi.mock('../../app/constants.js', async () => {
|
||||
const actual = await vi.importActual('../../app/constants.js');
|
||||
return {
|
||||
...actual,
|
||||
STRIPE_ENABLED: true,
|
||||
STRIPE_SK: 'sk_test_mock_key_for_testing',
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the stripe client to avoid actual Stripe API calls
|
||||
vi.mock('../../app/stripe.js', () => ({
|
||||
stripe: {
|
||||
customers: {
|
||||
retrieve: vi.fn().mockResolvedValue({
|
||||
deleted: false,
|
||||
currency: 'usd',
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BillingLimitService - Critical Enforcement', () => {
|
||||
let projectId: string;
|
||||
let contactId: string;
|
||||
|
||||
@@ -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 {CampaignService} from '../CampaignService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Mock STRIPE_ENABLED for billing limit tests
|
||||
vi.mock('../../app/constants.js', async () => {
|
||||
const actual = await vi.importActual('../../app/constants.js');
|
||||
return {
|
||||
...actual,
|
||||
STRIPE_ENABLED: true,
|
||||
STRIPE_SK: 'sk_test_mock_key_for_testing',
|
||||
};
|
||||
});
|
||||
|
||||
describe('CampaignService', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
@@ -356,4 +366,227 @@ describe('CampaignService', () => {
|
||||
expect(matching).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('should throw error when campaign has no recipients', async () => {
|
||||
// Create campaign with no contacts in project
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
});
|
||||
|
||||
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow('Campaign has no recipients');
|
||||
});
|
||||
|
||||
it('should send campaign successfully when recipients are under billing limit', async () => {
|
||||
// Set billing limit for campaigns
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 100},
|
||||
});
|
||||
|
||||
// Create 5 subscribed contacts
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Create campaign targeting all contacts
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
// Should send successfully (5 recipients < 100 limit)
|
||||
const sentCampaign = await CampaignService.send(projectId, campaign.id);
|
||||
|
||||
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
|
||||
expect(sentCampaign.totalRecipients).toBe(5);
|
||||
});
|
||||
|
||||
it('should throw 403 error when campaign would exceed billing limit', async () => {
|
||||
// Set billing limit for campaigns to 10
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 10},
|
||||
});
|
||||
|
||||
// Create 5 existing campaign emails (usage = 5)
|
||||
const contact = await factories.createContact({projectId, subscribed: true});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: 'CAMPAIGN',
|
||||
});
|
||||
}
|
||||
|
||||
// Create campaign with 10 subscribed contacts (would result in 15 total)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
// Should throw error because 5 + 11 = 16 > 10 limit (11 contacts: 1 from email creation + 10 new)
|
||||
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(/exceed billing limit/i);
|
||||
});
|
||||
|
||||
it('should throw 403 error when scheduled campaign would exceed billing limit', async () => {
|
||||
// Set billing limit for campaigns to 20
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 20},
|
||||
});
|
||||
|
||||
// Create 15 existing campaign emails
|
||||
const contact = await factories.createContact({projectId, subscribed: true});
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: 'CAMPAIGN',
|
||||
});
|
||||
}
|
||||
|
||||
// Create campaign with 10 subscribed contacts (would result in 25 total)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
|
||||
|
||||
// Should throw error when scheduling because 15 + 10 = 25 > 20 limit
|
||||
await expect(CampaignService.send(projectId, campaign.id, scheduledFor)).rejects.toThrow(
|
||||
/Cannot schedule campaign.*exceed billing limit/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should send campaign successfully when billing limit is null (unlimited)', async () => {
|
||||
// Set billing limit to null (unlimited)
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: null},
|
||||
});
|
||||
|
||||
// Create 1000 subscribed contacts
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
// Should send successfully (no limit)
|
||||
const sentCampaign = await CampaignService.send(projectId, campaign.id);
|
||||
|
||||
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
|
||||
expect(sentCampaign.totalRecipients).toBe(1000);
|
||||
});
|
||||
|
||||
it('should allow campaign that exactly reaches billing limit', async () => {
|
||||
// Set billing limit for campaigns to 10
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 10},
|
||||
});
|
||||
|
||||
// Create 5 existing campaign emails
|
||||
const contact = await factories.createContact({projectId, subscribed: true});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: 'CAMPAIGN',
|
||||
});
|
||||
}
|
||||
|
||||
// Create 4 more subscribed contacts (total of 5 with the one above: 1 + 4 = 5)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
// Should send successfully because 5 + 5 = 10 (exactly at limit)
|
||||
const sentCampaign = await CampaignService.send(projectId, campaign.id);
|
||||
|
||||
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
|
||||
expect(sentCampaign.totalRecipients).toBe(5);
|
||||
});
|
||||
|
||||
it('should throw error when campaign has already been sent', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.SENT,
|
||||
});
|
||||
|
||||
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(
|
||||
'Campaign has already been sent or is currently sending',
|
||||
);
|
||||
});
|
||||
|
||||
it('should schedule campaign successfully when recipients are under billing limit', async () => {
|
||||
// Set billing limit for campaigns
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 50},
|
||||
});
|
||||
|
||||
// Create 10 subscribed contacts
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
|
||||
|
||||
// Should schedule successfully (10 recipients < 50 limit)
|
||||
const scheduledCampaign = await CampaignService.send(projectId, campaign.id, scheduledFor);
|
||||
|
||||
expect(scheduledCampaign.status).toBe(CampaignStatus.SCHEDULED);
|
||||
expect(scheduledCampaign.scheduledFor).toEqual(scheduledFor);
|
||||
expect(scheduledCampaign.totalRecipients).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import {ActionSchemas} from '@plunk/shared';
|
||||
import {EmailService} from '../EmailService';
|
||||
@@ -669,7 +669,6 @@ describe('EmailService', () => {
|
||||
// ========================================
|
||||
describe('Attachment Schema Validation', () => {
|
||||
it('should validate attachment count limit (max 10)', () => {
|
||||
|
||||
const tooManyAttachments = Array.from({length: 11}, (_, i) => ({
|
||||
filename: `file${i}.txt`,
|
||||
content: Buffer.from('content').toString('base64'),
|
||||
@@ -690,7 +689,6 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should validate attachment size limit (10MB total)', () => {
|
||||
|
||||
// Exceeds ~13.3M base64 chars limit
|
||||
const largeContent = 'A'.repeat(14000000);
|
||||
|
||||
@@ -711,11 +709,11 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should accept attachments within size limit', () => {
|
||||
|
||||
const validContent = Buffer.from('Small file content').toString('base64');
|
||||
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
from: '[email protected]',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
attachments: [
|
||||
@@ -731,7 +729,6 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should reject attachment with missing required fields', () => {
|
||||
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
subject: 'Test',
|
||||
@@ -748,7 +745,6 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should reject attachment with empty filename', () => {
|
||||
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
subject: 'Test',
|
||||
@@ -766,7 +762,6 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should reject attachment with filename exceeding 255 chars', () => {
|
||||
|
||||
const tooLongFilename = 'a'.repeat(256) + '.pdf';
|
||||
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
@@ -786,18 +781,12 @@ describe('EmailService', () => {
|
||||
});
|
||||
|
||||
it('should accept valid attachment with various content types', () => {
|
||||
|
||||
const contentTypes = [
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'text/plain',
|
||||
'application/zip',
|
||||
];
|
||||
const contentTypes = ['application/pdf', 'image/png', 'image/jpeg', 'text/plain', 'application/zip'];
|
||||
|
||||
for (const contentType of contentTypes) {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
from: '[email protected]',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
attachments: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {WorkflowExecutionStatus, WorkflowTriggerType} from '@plunk/db';
|
||||
import {EventService} from '../EventService';
|
||||
import {Keys} from '../keys';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Mock Redis for caching tests - must be inline to avoid hoisting issues
|
||||
@@ -503,7 +504,7 @@ describe('EventService', () => {
|
||||
const {redis} = await import('../../database/redis');
|
||||
|
||||
// Set cache
|
||||
const cacheKey = `workflows:enabled:${projectId}`;
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
await redis.set(cacheKey, JSON.stringify([{id: 'test'}]));
|
||||
|
||||
// Verify cache exists
|
||||
@@ -852,4 +853,90 @@ describe('EventService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// RESERVED EVENT VALIDATION
|
||||
// ========================================
|
||||
describe('isReservedEvent', () => {
|
||||
describe('Email events (email.*)', () => {
|
||||
it('should identify email.sent as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.sent')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify email.delivery as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.delivery')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify email.open as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.open')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify email.click as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.click')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify email.bounce as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.bounce')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify email.complaint as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.complaint')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify any email.* pattern as reserved', () => {
|
||||
expect(EventService.isReservedEvent('email.custom')).toBe(true);
|
||||
expect(EventService.isReservedEvent('email.anything')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contact events', () => {
|
||||
it('should identify contact.subscribed as reserved', () => {
|
||||
expect(EventService.isReservedEvent('contact.subscribed')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify contact.unsubscribed as reserved', () => {
|
||||
expect(EventService.isReservedEvent('contact.unsubscribed')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not identify other contact.* events as reserved', () => {
|
||||
expect(EventService.isReservedEvent('contact.created')).toBe(false);
|
||||
expect(EventService.isReservedEvent('contact.updated')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Segment events (segment.*.entry, segment.*.exit)', () => {
|
||||
it('should identify segment.*.entry as reserved', () => {
|
||||
expect(EventService.isReservedEvent('segment.vip-users.entry')).toBe(true);
|
||||
expect(EventService.isReservedEvent('segment.premium.entry')).toBe(true);
|
||||
expect(EventService.isReservedEvent('segment.active-subscribers.entry')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify segment.*.exit as reserved', () => {
|
||||
expect(EventService.isReservedEvent('segment.vip-users.exit')).toBe(true);
|
||||
expect(EventService.isReservedEvent('segment.premium.exit')).toBe(true);
|
||||
expect(EventService.isReservedEvent('segment.active-subscribers.exit')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not identify other segment.* events as reserved', () => {
|
||||
expect(EventService.isReservedEvent('segment.created')).toBe(false);
|
||||
expect(EventService.isReservedEvent('segment.vip-users.updated')).toBe(false);
|
||||
expect(EventService.isReservedEvent('segment.premium')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-reserved events', () => {
|
||||
it('should not identify custom user events as reserved', () => {
|
||||
expect(EventService.isReservedEvent('user.signup')).toBe(false);
|
||||
expect(EventService.isReservedEvent('purchase.completed')).toBe(false);
|
||||
expect(EventService.isReservedEvent('order.placed')).toBe(false);
|
||||
expect(EventService.isReservedEvent('custom.event')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not identify events with similar prefixes as reserved', () => {
|
||||
expect(EventService.isReservedEvent('emails.sent')).toBe(false);
|
||||
expect(EventService.isReservedEvent('contacts.subscribed')).toBe(false);
|
||||
expect(EventService.isReservedEvent('segments.entry')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
|
||||
import {WorkflowService} from '../WorkflowService';
|
||||
import {Keys} from '../keys';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Mock Redis for caching tests - must be inline to avoid hoisting issues
|
||||
@@ -130,7 +131,7 @@ describe('WorkflowService', () => {
|
||||
|
||||
it('should invalidate cache when creating enabled workflow', async () => {
|
||||
const {redis} = await import('../../database/redis');
|
||||
const cacheKey = `workflows:enabled:${projectId}`;
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
|
||||
// Set cache
|
||||
await redis.set(cacheKey, JSON.stringify([{id: 'old'}]));
|
||||
@@ -286,7 +287,7 @@ describe('WorkflowService', () => {
|
||||
|
||||
it('should invalidate cache when enabling workflow', async () => {
|
||||
const {redis} = await import('../../database/redis');
|
||||
const cacheKey = `workflows:enabled:${projectId}`;
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
|
||||
const workflow = await factories.createWorkflow({
|
||||
projectId,
|
||||
@@ -326,7 +327,7 @@ describe('WorkflowService', () => {
|
||||
|
||||
it('should invalidate cache when deleting enabled workflow', async () => {
|
||||
const {redis} = await import('../../database/redis');
|
||||
const cacheKey = `workflows:enabled:${projectId}`;
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
|
||||
const workflow = await factories.createWorkflow({
|
||||
projectId,
|
||||
|
||||
@@ -15,4 +15,41 @@ export const Keys = {
|
||||
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;
|
||||
|
||||
@@ -34,8 +34,8 @@ export function buildEmailFieldsUpdate(data: {
|
||||
subject?: string;
|
||||
body?: string;
|
||||
from?: string;
|
||||
fromName?: string;
|
||||
replyTo?: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
}): Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput {
|
||||
return buildUpdateData(data) as Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
"@plunk/shared": "*",
|
||||
"@plunk/ui": "*",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.0.7",
|
||||
"next": "^16.0.10",
|
||||
"next-seo": "^6.6.0",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"clean": "rimraf node_modules .next .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.0",
|
||||
"@plunk/db": "*",
|
||||
"@plunk/shared": "*",
|
||||
"@plunk/ui": "*",
|
||||
@@ -19,6 +22,7 @@
|
||||
"@tiptap/extension-image": "^3.11.0",
|
||||
"@tiptap/extension-link": "^3.11.0",
|
||||
"@tiptap/extension-mention": "^3.11.0",
|
||||
"@tiptap/extension-placeholder": "^3.11.0",
|
||||
"@tiptap/extension-table": "^3.11.0",
|
||||
"@tiptap/extension-table-cell": "^3.11.0",
|
||||
"@tiptap/extension-table-header": "^3.11.0",
|
||||
@@ -30,16 +34,18 @@
|
||||
"@tiptap/starter-kit": "^3.11.0",
|
||||
"@tiptap/suggestion": "^3.11.0",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"@xyflow/react": "^12.9.3",
|
||||
"dagre": "^0.8.5",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^12.23.24",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.0.7",
|
||||
"next": "^16.0.10",
|
||||
"next-seo": "^6.6.0",
|
||||
"nuqs": "^2.7.3",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"react-intl": "^7.1.14",
|
||||
"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 {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {BillingLimitSchemas} from '@plunk/shared';
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import {AlertCircle, AlertTriangle, Check} from 'lucide-react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
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';
|
||||
|
||||
// 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,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(costInCents / 100);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Fallback if currency is invalid
|
||||
return `${(costInCents / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
||||
}
|
||||
@@ -167,8 +167,8 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm">
|
||||
You're on the free tier with 1,000 emails per month. Upgrade to a paid subscription for
|
||||
unlimited emails or custom limits.
|
||||
You're on the free tier with 1,000 emails per month. Upgrade to a paid subscription for unlimited
|
||||
emails or custom limits.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
@@ -254,9 +254,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum workflow emails per month. Leave empty for unlimited.
|
||||
{estimatedCost && (
|
||||
<span className="text-neutral-500"> ≈ {estimatedCost}/month</span>
|
||||
)}
|
||||
{estimatedCost && <span className="text-neutral-500"> ≈ {estimatedCost}/month</span>}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -286,9 +284,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum campaign emails per month. Leave empty for unlimited.
|
||||
{estimatedCost && (
|
||||
<span className="text-neutral-500"> ≈ {estimatedCost}/month</span>
|
||||
)}
|
||||
{estimatedCost && <span className="text-neutral-500"> ≈ {estimatedCost}/month</span>}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -318,9 +314,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum transactional emails per month. Leave empty for unlimited.
|
||||
{estimatedCost && (
|
||||
<span className="text-neutral-500"> ≈ {estimatedCost}/month</span>
|
||||
)}
|
||||
{estimatedCost && <span className="text-neutral-500"> ≈ {estimatedCost}/month</span>}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</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">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<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.
|
||||
</p>
|
||||
</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">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-xs">
|
||||
<p className="text-sm">
|
||||
<strong>Warning:</strong> You've used {Math.round(usage.percentage)}% of your{' '}
|
||||
{category.toLowerCase()} email limit.
|
||||
</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">
|
||||
{project.name.charAt(0).toUpperCase()}
|
||||
</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 && (
|
||||
<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 {TextStyle} from '@tiptap/extension-text-style';
|
||||
import {Link} from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import {Variable} from './VariableExtension';
|
||||
import {setAvailableVariables, VariableMention} from './VariableMention';
|
||||
import {Toolbar} from './Toolbar';
|
||||
import {ResizableImage} from './ResizableImage';
|
||||
import {HtmlEditor} from './HtmlEditor';
|
||||
import {useContactFields, useContacts} from '../../lib/hooks/useContacts';
|
||||
import {useConfig} from '../../lib/hooks/useConfig';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectValue
|
||||
} from '@plunk/ui';
|
||||
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
|
||||
import {network} from '../../lib/network';
|
||||
@@ -153,6 +155,9 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
||||
ResizableImage,
|
||||
Variable,
|
||||
VariableMention,
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || 'Your next email starts here!',
|
||||
}),
|
||||
],
|
||||
// Only initialize with content if starting in visual mode
|
||||
// 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">
|
||||
<textarea
|
||||
<div className="min-h-[400px] max-h-[600px] overflow-hidden">
|
||||
<HtmlEditor
|
||||
value={htmlContent}
|
||||
onChange={e => handleHtmlChange(e.target.value)}
|
||||
placeholder={placeholder || '<h1>Welcome!</h1><p>Your email content here...</p>'}
|
||||
className="w-full h-full px-4 py-3 text-sm font-mono resize-none min-h-[400px] focus:outline-none"
|
||||
onChange={handleHtmlChange}
|
||||
placeholder={placeholder || 'Your next email starts here!'}
|
||||
/>
|
||||
</div>
|
||||
{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 (
|
||||
<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 */}
|
||||
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
|
||||
<Button
|
||||
@@ -424,7 +424,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
|
||||
</Button>
|
||||
{showColorPicker && (
|
||||
<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'}}
|
||||
>
|
||||
{/* Custom color input */}
|
||||
@@ -508,7 +508,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
|
||||
<Link className="h-4 w-4" />
|
||||
</Button>
|
||||
{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">
|
||||
<input
|
||||
type="url"
|
||||
|
||||
@@ -28,10 +28,15 @@ export function EmailSettings({
|
||||
onReplyToChange,
|
||||
fromPlaceholder = 'hello',
|
||||
fromNamePlaceholder = 'Your Company',
|
||||
replyToPlaceholder = 'support',
|
||||
replyToPlaceholder,
|
||||
showFromNameHelpText = false,
|
||||
layout = 'grid',
|
||||
}: 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 gridClassName = layout === 'grid' ? 'grid gap-4 md:grid-cols-2' : 'space-y-4';
|
||||
|
||||
@@ -73,7 +78,7 @@ export function EmailSettings({
|
||||
label="Reply-To Email"
|
||||
value={replyTo}
|
||||
onChange={onReplyToChange}
|
||||
placeholder={replyToPlaceholder}
|
||||
placeholder={effectiveReplyToPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</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
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* Get the user's timezone
|
||||
*/
|
||||
@@ -106,3 +108,21 @@ export function formatUTCDateTime(date: Date): string {
|
||||
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 {useUser} from '../lib/hooks/useUser';
|
||||
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
|
||||
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
|
||||
const NO_PROJECT_ROUTES = ['/projects/create'];
|
||||
@@ -28,7 +35,9 @@ function App({Component, pageProps}: AppProps) {
|
||||
function AuthGuard({children}: {children: React.ReactNode}) {
|
||||
const {data: user, isLoading} = useUser();
|
||||
const router = useRouter();
|
||||
const isPublicRoute = PUBLIC_ROUTES.includes(router.pathname);
|
||||
const isPublicRoute = PUBLIC_ROUTES.some(route =>
|
||||
router.pathname === route || router.pathname.startsWith(`${route}/`)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 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) {
|
||||
const router = useRouter();
|
||||
const isPublicRoute = PUBLIC_ROUTES.includes(router.pathname);
|
||||
const isPublicRoute = PUBLIC_ROUTES.some(route =>
|
||||
router.pathname === route || router.pathname.startsWith(`${route}/`)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
Info,
|
||||
Mail,
|
||||
MousePointer,
|
||||
Save,
|
||||
@@ -86,7 +87,7 @@ export default function CampaignDetailsPage() {
|
||||
id && campaign?.data.status !== CampaignStatus.DRAFT ? `/campaigns/${id}/stats` : null,
|
||||
{
|
||||
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
|
||||
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},
|
||||
);
|
||||
|
||||
@@ -213,14 +214,28 @@ export default function CampaignDetailsPage() {
|
||||
subject: editedCampaign.subject,
|
||||
body: editedCampaign.body,
|
||||
from: editedCampaign.from,
|
||||
fromName: editedCampaign.fromName || undefined,
|
||||
replyTo: editedCampaign.replyTo || undefined,
|
||||
fromName: editedCampaign.fromName || null,
|
||||
replyTo: editedCampaign.replyTo || null,
|
||||
audienceType: editedCampaign.audienceType,
|
||||
segmentId: editedCampaign.segmentId || undefined,
|
||||
});
|
||||
// Silent save - no toast notification
|
||||
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) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update campaign');
|
||||
} finally {
|
||||
@@ -312,19 +327,9 @@ export default function CampaignDetailsPage() {
|
||||
const c = campaign.data;
|
||||
const s = stats?.data;
|
||||
|
||||
// Get recipient count for draft campaigns
|
||||
const getDraftRecipientCount = () => {
|
||||
if (!campaign?.data) return 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;
|
||||
// Get recipient count for draft campaigns from the campaign's totalRecipients field
|
||||
// The backend calculates this for all audience types when the campaign is created/updated
|
||||
const draftRecipientCount = isEditMode && campaign?.data ? campaign.data.totalRecipients : 0;
|
||||
|
||||
// Render edit form for drafts
|
||||
if (isEditMode) {
|
||||
@@ -386,18 +391,37 @@ export default function CampaignDetailsPage() {
|
||||
<ChevronDown className="h-4 w-4 sm:ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setIsTestEmailDialogOpen(true)}>
|
||||
<TestTube className="h-4 w-4 mr-2" />
|
||||
Send Test Email
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<DropdownMenuItem onClick={() => setIsTestEmailDialogOpen(true)} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<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 onClick={() => setShowSendDialog(true)}>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Now
|
||||
<DropdownMenuItem onClick={() => setShowSendDialog(true)} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<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 onClick={() => setIsScheduleDialogOpen(true)}>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Schedule for Later
|
||||
<DropdownMenuItem onClick={() => setIsScheduleDialogOpen(true)} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -497,11 +521,6 @@ export default function CampaignDetailsPage() {
|
||||
title="Segment"
|
||||
description="Target a defined group of contacts"
|
||||
/>
|
||||
<SelectItemWithDescription
|
||||
value={CampaignAudienceType.FILTERED}
|
||||
title="Filtered"
|
||||
description="Use advanced filter conditions"
|
||||
/>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -551,13 +570,20 @@ export default function CampaignDetailsPage() {
|
||||
|
||||
{/* Show recipient count */}
|
||||
{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">
|
||||
<Users className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">
|
||||
{draftRecipientCount.toLocaleString()} recipients
|
||||
</span>
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -565,7 +591,7 @@ export default function CampaignDetailsPage() {
|
||||
</div>
|
||||
|
||||
{/* Email Editor - Full Width */}
|
||||
<Card>
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader>
|
||||
<CardTitle>Email Content</CardTitle>
|
||||
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
|
||||
@@ -577,7 +603,6 @@ export default function CampaignDetailsPage() {
|
||||
setEditedCampaign({...editedCampaign, body});
|
||||
setHasChanges(true);
|
||||
}}
|
||||
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -972,13 +997,21 @@ export default function CampaignDetailsPage() {
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Scheduled For</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="h-4 w-4 text-neutral-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{formatFullDateTime(new Date(c.scheduledFor))}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
UTC: {formatUTCDateTime(new Date(c.scheduledFor))}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{formatFullDateTime(new Date(c.scheduledFor))}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
UTC: {formatUTCDateTime(new Date(c.scheduledFor))}
|
||||
</p>
|
||||
</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>
|
||||
@@ -987,21 +1020,15 @@ export default function CampaignDetailsPage() {
|
||||
{/* Sent At */}
|
||||
{c.sentAt && (
|
||||
<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">
|
||||
<Send className="h-4 w-4 text-neutral-400" />
|
||||
<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>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ import {EmailFormValidator} from '../../lib/validation';
|
||||
import {ArrowLeft, Save, Users} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useState} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
@@ -43,9 +43,92 @@ export default function CreateCampaignPage() {
|
||||
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
|
||||
const [segmentId, setSegmentId] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingTemplate, setLoadingTemplate] = useState(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) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -64,8 +147,8 @@ export default function CreateCampaignPage() {
|
||||
subject,
|
||||
body,
|
||||
from,
|
||||
fromName: fromName || undefined,
|
||||
replyTo: replyTo || undefined,
|
||||
fromName: fromName || null,
|
||||
replyTo: replyTo || null,
|
||||
audienceType,
|
||||
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
|
||||
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
|
||||
@@ -94,6 +177,26 @@ export default function CreateCampaignPage() {
|
||||
<>
|
||||
<NextSeo title="Create Campaign" />
|
||||
<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">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
@@ -188,7 +291,7 @@ export default function CreateCampaignPage() {
|
||||
</Card>
|
||||
|
||||
{/* Email Content */}
|
||||
<Card>
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader>
|
||||
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" />
|
||||
</CardHeader>
|
||||
@@ -197,11 +300,7 @@ export default function CreateCampaignPage() {
|
||||
<Label htmlFor="body">
|
||||
Email Body <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<EmailEditor
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
|
||||
/>
|
||||
<EmailEditor value={body} onChange={setBody} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -283,6 +382,18 @@ export default function CreateCampaignPage() {
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -339,6 +450,13 @@ export default function CreateCampaignPage() {
|
||||
<span className="font-medium">{estimatedRecipients.toLocaleString()}</span>
|
||||
</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>
|
||||
|
||||
{/* Info Note */}
|
||||
|
||||
@@ -7,22 +7,31 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import type {Campaign} from '@plunk/db';
|
||||
import type {Campaign, Template} from '@plunk/db';
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog';
|
||||
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
|
||||
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 Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface PaginatedCampaigns {
|
||||
campaigns: Campaign[];
|
||||
@@ -33,12 +42,15 @@ interface PaginatedCampaigns {
|
||||
}
|
||||
|
||||
export default function CampaignsPage() {
|
||||
const router = useRouter();
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [campaignToDelete, setCampaignToDelete] = useState<string | null>(null);
|
||||
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
|
||||
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
|
||||
|
||||
const {data, mutate, isLoading} = useSWR<PaginatedCampaigns>(
|
||||
`/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 (
|
||||
<>
|
||||
<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` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/campaigns/create" className="w-full sm:w-auto">
|
||||
<Button className="w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Create Campaign</span>
|
||||
<span className="sm:hidden">Create</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Create Campaign</span>
|
||||
<span className="sm:hidden">Create</span>
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -171,12 +308,60 @@ export default function CampaignsPage() {
|
||||
? 'Try adjusting your filters or create a new campaign.'
|
||||
: 'Create your first campaign to send emails to your contacts.'}
|
||||
</p>
|
||||
<Link href="/campaigns/create">
|
||||
<Button size="lg">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Your First Campaign
|
||||
</Button>
|
||||
</Link>
|
||||
{statusFilter === 'ALL' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="lg">
|
||||
<Plus className="h-4 w-4" />
|
||||
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>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -216,11 +401,20 @@ export default function CampaignsPage() {
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Users className="h-3.5 w-3.5 text-blue-600" />
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{campaign.status === 'DRAFT' && <p className="text-xs text-blue-600 mt-1">Estimated</p>}
|
||||
</div>
|
||||
|
||||
{/* Open Rate */}
|
||||
@@ -273,6 +467,25 @@ export default function CampaignsPage() {
|
||||
)}
|
||||
</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 */}
|
||||
<div className="flex gap-2 pt-2 border-t border-neutral-100">
|
||||
<Link href={`/campaigns/${campaign.id}`} className="flex-1">
|
||||
@@ -356,6 +569,18 @@ export default function CampaignsPage() {
|
||||
confirmText="Delete Campaign"
|
||||
variant="destructive"
|
||||
/>
|
||||
|
||||
<TemplateSelectionDialog
|
||||
open={showTemplateDialog}
|
||||
onOpenChange={setShowTemplateDialog}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
/>
|
||||
|
||||
<CampaignSelectionDialog
|
||||
open={showCampaignDialog}
|
||||
onOpenChange={setShowCampaignDialog}
|
||||
onSelectCampaign={handleSelectCampaign}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,20 +7,21 @@ import {
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
Input,
|
||||
Label,
|
||||
Label
|
||||
} from '@plunk/ui';
|
||||
import type {Contact} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
||||
import {ActivityFeed} from '../../components/ActivityFeed';
|
||||
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 {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {ContactSchemas} from '@plunk/shared';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default function ContactDetailPage() {
|
||||
const router = useRouter();
|
||||
@@ -250,34 +251,6 @@ export default function ContactDetailPage() {
|
||||
</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">
|
||||
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||
<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>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -13,12 +13,13 @@ import {
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
Switch
|
||||
} from '@plunk/ui';
|
||||
import type {Contact} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
||||
import {network} from '../../lib/network';
|
||||
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||
import {
|
||||
CheckCircle,
|
||||
ChevronLeft,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
XCircle,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
@@ -40,6 +41,7 @@ import {useEffect, useRef, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {ContactSchemas} from '@plunk/shared';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface PaginatedContacts {
|
||||
contacts: Contact[];
|
||||
@@ -279,7 +281,12 @@ export default function ContactsPage() {
|
||||
</span>
|
||||
</td>
|
||||
<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 className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
@@ -324,9 +331,12 @@ export default function ContactsPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-neutral-500">
|
||||
{new Date(contact.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<div className="group relative inline-block cursor-help">
|
||||
<span className="text-xs text-neutral-500">{formatRelativeTime(contact.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(contact.createdAt).format('Do MMMM YYYY, h:mm A')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href={`/contacts/${contact.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
|
||||
+105
-87
@@ -7,7 +7,7 @@ import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardTitle
|
||||
} from '@plunk/ui';
|
||||
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
@@ -15,14 +15,19 @@ import Link from 'next/link';
|
||||
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
|
||||
import {DashboardLayout} from '../components/DashboardLayout';
|
||||
import {QuickStart} from '../components/QuickStart';
|
||||
import {SecurityWarningBanner} from '../components/SecurityWarningBanner';
|
||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
|
||||
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
||||
import {useProjectSecurity} from '../lib/hooks/useProjectSecurity';
|
||||
import {useConfig} from '../lib/hooks/useConfig';
|
||||
|
||||
export default function Index() {
|
||||
const {activeProject} = useActiveProject();
|
||||
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
||||
const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id);
|
||||
const {securityMetrics} = useProjectSecurity(activeProject?.id);
|
||||
const {data: config} = useConfig();
|
||||
|
||||
const stats = [
|
||||
{
|
||||
@@ -52,95 +57,108 @@ export default function Index() {
|
||||
<NextSeo title="Dashboard" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-8">
|
||||
{/* Project Disabled Banner */}
|
||||
{activeProject && activeProject.disabled && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Project Disabled - Read-Only Mode</AlertTitle>
|
||||
<AlertDescription>
|
||||
This project has been disabled due to security violations (high bounce or complaint rates). All scheduled
|
||||
campaigns and workflows have been cancelled. The project is now in read-only mode - you can view your data
|
||||
but cannot create, update, or delete anything. Please contact support to resolve this issue.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* Project Disabled Banner */}
|
||||
{activeProject && activeProject.disabled && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Project Disabled - Read-Only Mode</AlertTitle>
|
||||
<AlertDescription>
|
||||
This project has been disabled due to security violations (high bounce or complaint rates). All
|
||||
scheduled campaigns and workflows have been cancelled. The project is now in read-only mode - you can
|
||||
view your data but cannot create, update, or delete anything. Please contact support to resolve this
|
||||
issue.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Subscription Warning Banner */}
|
||||
{activeProject && !activeProject.disabled && !activeProject.subscription && (
|
||||
<Alert variant="warning">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Upgrade to remove Plunk branding</AlertTitle>
|
||||
<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>
|
||||
<Link href="/settings?tab=billing">
|
||||
<Button size="sm" className="w-full sm:w-auto">Upgrade Now</Button>
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* Security Warning Banner */}
|
||||
{activeProject && !activeProject.disabled && securityMetrics && (
|
||||
<SecurityWarningBanner status={securityMetrics.status} />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
|
||||
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
|
||||
Welcome back to {activeProject?.name || 'Plunk'}. Here's what's happening with your emails.
|
||||
</p>
|
||||
{/* Subscription Warning Banner */}
|
||||
{activeProject &&
|
||||
!activeProject.disabled &&
|
||||
!activeProject.subscription &&
|
||||
config?.features.billing.enabled && (
|
||||
<Alert variant="warning">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Upgrade to remove Plunk branding</AlertTitle>
|
||||
<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>
|
||||
<Link href="/settings?tab=billing">
|
||||
<Button size="sm" className="w-full sm:w-auto">
|
||||
Upgrade Now
|
||||
</Button>
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
|
||||
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
|
||||
Welcome back to {activeProject?.name || 'Plunk'}. Here's what's happening with your emails.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map(stat => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Card key={stat.name}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{stat.name}</CardDescription>
|
||||
<Icon className="h-4 w-4 text-neutral-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{stat.value}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions & API Keys */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Quick Start */}
|
||||
<QuickStart setupState={setupState} isLoading={isLoadingSetupState} />
|
||||
|
||||
{/* API Keys */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{activeProject ? (
|
||||
<>
|
||||
<ApiKeyDisplay
|
||||
label="Public Key"
|
||||
value={activeProject.public}
|
||||
description="Use this key for client-side integrations"
|
||||
/>
|
||||
<ApiKeyDisplay
|
||||
label="Secret Key"
|
||||
value={activeProject.secret}
|
||||
description="Keep this key secure and never expose it publicly"
|
||||
isSecret
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">No project selected</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map(stat => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Card key={stat.name}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{stat.name}</CardDescription>
|
||||
<Icon className="h-4 w-4 text-neutral-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{stat.value}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions & API Keys */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Quick Start */}
|
||||
<QuickStart setupState={setupState} isLoading={isLoadingSetupState} />
|
||||
|
||||
{/* API Keys */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{activeProject ? (
|
||||
<>
|
||||
<ApiKeyDisplay
|
||||
label="Public Key"
|
||||
value={activeProject.public}
|
||||
description="Use this key for client-side integrations"
|
||||
/>
|
||||
<ApiKeyDisplay
|
||||
label="Secret Key"
|
||||
value={activeProject.secret}
|
||||
description="Keep this key secure and never expose it publicly"
|
||||
isSecret
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">No project selected</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import type {Contact, Segment} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
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 {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
@@ -21,6 +21,7 @@ import useSWR from 'swr';
|
||||
import type {FilterCondition} from '@plunk/types';
|
||||
import {SegmentSchemas} from '@plunk/shared';
|
||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface PaginatedContacts {
|
||||
contacts: Contact[];
|
||||
@@ -388,22 +389,6 @@ export default function SegmentDetailPage() {
|
||||
<CardTitle>Metadata</CardTitle>
|
||||
</CardHeader>
|
||||
<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">
|
||||
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||
<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>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -13,12 +13,14 @@ import type {Segment} from '@plunk/db';
|
||||
import type {FilterCondition} from '@plunk/types';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
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 Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// Helper function to count total filters in a condition
|
||||
function countFiltersInCondition(condition: unknown): number {
|
||||
@@ -194,8 +196,22 @@ export default function SegmentsPage() {
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="text-xs text-neutral-500 pt-2 border-t border-neutral-200">
|
||||
Created {new Date(segment.createdAt).toLocaleDateString()}
|
||||
<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(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>
|
||||
</CardContent>
|
||||
|
||||
@@ -37,7 +37,16 @@ import {
|
||||
} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
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 {useRouter} from 'next/router';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
@@ -50,14 +59,16 @@ import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
|
||||
import {SmtpSettings} from '../../components/SmtpSettings';
|
||||
import {DataManagementSettings} from '../../components/DataManagementSettings';
|
||||
import {TeamSettings} from '../../components/TeamSettings';
|
||||
import {SecuritySettings} from '../../components/SecuritySettings';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {network} from '../../lib/network';
|
||||
import {useProjects} from '../../lib/hooks/useProject';
|
||||
import {useConfig} from '../../lib/hooks/useConfig';
|
||||
import {useUser} from '../../lib/hooks/useUser';
|
||||
import {useProjectSecurity} from '../../lib/hooks/useProjectSecurity';
|
||||
import useSWR from 'swr';
|
||||
|
||||
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team';
|
||||
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team' | 'security';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
@@ -71,6 +82,7 @@ const buildTabs = (options: {billingEnabled: boolean; smtpEnabled: boolean}): Ta
|
||||
const allTabs: Tab[] = [
|
||||
{id: 'general', label: 'General', icon: SettingsIcon},
|
||||
{id: 'team', label: 'Team', icon: Users},
|
||||
{id: 'security', label: 'Security', icon: Shield},
|
||||
{id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled},
|
||||
{id: 'domains', label: 'Domains', icon: Globe},
|
||||
{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 currentUserRole = currentUserMembership?.role || 'MEMBER';
|
||||
|
||||
const {securityMetrics, isLoading: isLoadingSecurityMetrics} = useProjectSecurity(activeProject?.id);
|
||||
|
||||
const billingEnabled = config?.features.billing.enabled ?? false;
|
||||
const smtpEnabled = config?.features.smtp.enabled ?? false;
|
||||
const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false;
|
||||
@@ -368,9 +382,9 @@ export default function Settings() {
|
||||
{buildTabs({billingEnabled, smtpEnabled}).map(tab => {
|
||||
const Icon = tab.icon;
|
||||
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" />
|
||||
{tab.label}
|
||||
<span className="hidden md:inline">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
@@ -647,7 +661,8 @@ export default function Settings() {
|
||||
<span className="font-medium">No Active Subscription</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -690,6 +705,24 @@ export default function Settings() {
|
||||
/>
|
||||
</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 */}
|
||||
<TabsContent value="domains">
|
||||
<DomainsSettings projectId={activeProject.id} />
|
||||
|
||||
@@ -93,8 +93,8 @@ export default function TemplateEditorPage() {
|
||||
subject: editedTemplate.subject,
|
||||
body: editedTemplate.body,
|
||||
from: editedTemplate.from,
|
||||
fromName: editedTemplate.fromName || undefined,
|
||||
replyTo: editedTemplate.replyTo || undefined,
|
||||
fromName: editedTemplate.fromName || null,
|
||||
replyTo: editedTemplate.replyTo || null,
|
||||
type: editedTemplate.type,
|
||||
});
|
||||
|
||||
@@ -279,7 +279,7 @@ export default function TemplateEditorPage() {
|
||||
|
||||
{/* Email Body */}
|
||||
<div>
|
||||
<Card>
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader>
|
||||
<CardTitle>Email Body</CardTitle>
|
||||
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
||||
@@ -288,7 +288,6 @@ export default function TemplateEditorPage() {
|
||||
<EmailEditor
|
||||
value={editedTemplate.body || ''}
|
||||
onChange={body => setEditedTemplate({...editedTemplate, body})}
|
||||
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -58,8 +58,8 @@ export default function CreateTemplatePage() {
|
||||
subject,
|
||||
body,
|
||||
from,
|
||||
fromName: fromName || undefined,
|
||||
replyTo: replyTo || undefined,
|
||||
fromName: fromName || null,
|
||||
replyTo: replyTo || null,
|
||||
type,
|
||||
});
|
||||
|
||||
@@ -179,17 +179,13 @@ export default function CreateTemplatePage() {
|
||||
</Card>
|
||||
|
||||
{/* Email Body */}
|
||||
<Card>
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader>
|
||||
<CardTitle>Email Body</CardTitle>
|
||||
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmailEditor
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
|
||||
/>
|
||||
<EmailEditor value={body} onChange={setBody} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
import type {Template} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
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 Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface PaginatedTemplates {
|
||||
templates: Template[];
|
||||
@@ -252,8 +254,22 @@ export default function TemplatesPage() {
|
||||
<p className="text-xs text-neutral-500 mb-1">From</p>
|
||||
<p className="text-sm text-neutral-700">{template.from}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-neutral-500 pt-2 border-t border-neutral-100">
|
||||
<div>Created {new Date(template.createdAt).toLocaleDateString()}</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(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 && (
|
||||
<div>
|
||||
Reply to: <span className="text-neutral-700">{template.replyTo}</span>
|
||||
|
||||
@@ -57,6 +57,7 @@ import useSWR from 'swr';
|
||||
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
||||
import {ReactFlowProvider} from '@xyflow/react';
|
||||
import {WorkflowSchemas} from '@plunk/shared';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface WorkflowWithDetails extends Workflow {
|
||||
steps: (WorkflowStep & {
|
||||
@@ -611,7 +612,12 @@ export default function WorkflowEditorPage() {
|
||||
{execution.currentStep?.name ?? '-'}
|
||||
</td>
|
||||
<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 className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{(execution.status === 'RUNNING' || execution.status === 'WAITING') && (
|
||||
|
||||
@@ -23,13 +23,15 @@ import {
|
||||
import type {Workflow} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
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 Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {WorkflowSchemas} from '@plunk/shared';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface PaginatedWorkflows {
|
||||
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>{' '}
|
||||
executions
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -5,15 +5,32 @@ description: Deploy with Docker Compose
|
||||
|
||||
## 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
|
||||
git clone https://github.com/useplunk/plunk.git
|
||||
cd plunk
|
||||
cp .env.self-host.example .env
|
||||
# Edit .env (see Environment Variables)
|
||||
docker compose up -d
|
||||
# Required
|
||||
JWT_SECRET="$(openssl rand -base64 32)"
|
||||
DB_PASSWORD="your-secure-password"
|
||||
|
||||
# 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
|
||||
|
||||
@@ -33,7 +50,6 @@ See [Environment Variables](/self-hosting/environment-variables) for all configu
|
||||
| 465 | SMTP (implicit TLS) |
|
||||
| 587 | SMTP (STARTTLS) |
|
||||
| 9000 | Minio API |
|
||||
| 9001 | Minio Console |
|
||||
|
||||
## Running Individual Services
|
||||
|
||||
|
||||
@@ -33,28 +33,7 @@ description: Configure email delivery
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Request Production Access
|
||||
|
||||
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
|
||||
## 2. Create SNS Topic
|
||||
|
||||
1. Go to SNS Console → Topics → Create topic
|
||||
2. Type: Standard
|
||||
@@ -65,7 +44,7 @@ SES starts in sandbox mode (verified addresses only).
|
||||
- Endpoint: `https://api.yourdomain.com/webhooks/sns`
|
||||
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
|
||||
|
||||
@@ -81,7 +60,7 @@ SES starts in sandbox mode (verified addresses only).
|
||||
1. Create another set named `plunk-no-tracking`
|
||||
2. Add event destination with only: **Sends, Deliveries, Bounces, Complaints**
|
||||
|
||||
## 7. Configure Environment
|
||||
## 4. Configure Environment
|
||||
|
||||
```bash
|
||||
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_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
|
||||
- AWS SES account (for sending emails)
|
||||
- Domain name (for production)
|
||||
- Domain name with subdomains for each service
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/useplunk/plunk.git
|
||||
cd plunk
|
||||
cp .env.self-host.example .env
|
||||
# Edit .env with your settings
|
||||
docker compose up -d
|
||||
```
|
||||
1. Download the [docker-compose.yml](https://raw.githubusercontent.com/useplunk/plunk/next/docker-compose.yml)
|
||||
2. Create environment variables with your configuration (see [Environment Variables](/self-hosting/environment-variables))
|
||||
3. Run `docker compose up -d`
|
||||
|
||||
## Access
|
||||
## Domain Setup
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Dashboard | http://app.localhost |
|
||||
| API | http://api.localhost |
|
||||
| Docs | http://docs.localhost |
|
||||
| Minio Console | http://localhost:9001 |
|
||||
Configure these subdomains pointing to your server:
|
||||
|
||||
| Subdomain | Service |
|
||||
|-----------|---------|
|
||||
| `app.yourdomain.com` | Dashboard |
|
||||
| `api.yourdomain.com` | API |
|
||||
| `docs.yourdomain.com` | Documentation |
|
||||
| `www.yourdomain.com` | Landing page |
|
||||
| `smtp.yourdomain.com` | SMTP relay (optional) |
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
"fumadocs-openapi": "^10.0.11",
|
||||
"fumadocs-ui": "16.0.8",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.0.7",
|
||||
"next": "^16.0.10",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"shiki": "^3.15.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
|
||||
@@ -183,6 +183,13 @@ services:
|
||||
# If using PEM files, SMTP_DOMAIN is optional
|
||||
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:
|
||||
# Persistent storage for application data
|
||||
- plunk_data:/app/data
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- 55432:5432
|
||||
environment:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plunk",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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([projectId, contactId, name, createdAt]) // For event-based segment queries (fast!)
|
||||
@@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
|
||||
@@map("events")
|
||||
}
|
||||
|
||||
@@ -14,14 +14,13 @@
|
||||
"react-email": "5.0.1",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plunk/db": "*",
|
||||
"@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": {
|
||||
".": "./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';
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<>
|
||||
<Img src="https://www.swyp.be/favicon/web-app-manifest-192x192.png" alt="Swyp Logo" width="40" height="40" />
|
||||
</>
|
||||
<Section className="border-b border-gray-100 bg-white px-8 py-8">
|
||||
<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 './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 {
|
||||
to: string;
|
||||
from: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export async function sendEmail({to, subject, body}: SendEmailParams) {
|
||||
const apiUrl = process.env.API_URI ?? 'http://localhost:8080';
|
||||
export async function sendEmail({to, from, subject, body}: SendEmailParams) {
|
||||
const apiUrl = process.env.API_URI;
|
||||
const res = await fetch(`${apiUrl}/v1/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -14,6 +15,7 @@ export async function sendEmail({to, subject, body}: SendEmailParams) {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to,
|
||||
from,
|
||||
subject,
|
||||
body,
|
||||
}),
|
||||
|
||||
@@ -142,8 +142,8 @@ export const TemplateSchemas = {
|
||||
subject: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
from: email,
|
||||
fromName: z.string().max(100).optional(),
|
||||
replyTo: email.optional(),
|
||||
fromName: z.string().max(100).nullish(),
|
||||
replyTo: email.nullish(),
|
||||
type: z.nativeEnum(TemplateType).default('MARKETING'),
|
||||
}),
|
||||
update: z.object({
|
||||
@@ -152,8 +152,8 @@ export const TemplateSchemas = {
|
||||
subject: z.string().min(1).optional(),
|
||||
body: z.string().min(1).optional(),
|
||||
from: email.optional(),
|
||||
fromName: z.string().max(100).optional(),
|
||||
replyTo: email.optional(),
|
||||
fromName: z.string().max(100).nullish(),
|
||||
replyTo: email.nullish(),
|
||||
type: z.nativeEnum(TemplateType).optional(),
|
||||
}),
|
||||
};
|
||||
@@ -282,8 +282,8 @@ export const CampaignSchemas = {
|
||||
subject: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
from: email,
|
||||
fromName: z.string().max(100).optional(),
|
||||
replyTo: email.optional(),
|
||||
fromName: z.string().max(100).nullish(),
|
||||
replyTo: email.nullish(),
|
||||
audienceType: z.nativeEnum(CampaignAudienceType),
|
||||
audienceCondition: filterConditionSchema.optional(),
|
||||
segmentId: uuid.optional(),
|
||||
@@ -297,8 +297,8 @@ export const CampaignSchemas = {
|
||||
subject: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
from: z.string().optional(),
|
||||
fromName: z.string().max(100).optional(),
|
||||
replyTo: z.string().optional(),
|
||||
fromName: z.string().max(100).nullish(),
|
||||
replyTo: z.string().nullish(),
|
||||
audienceType: z.nativeEnum(CampaignAudienceType).optional(),
|
||||
audienceCondition: filterConditionSchema.optional(),
|
||||
segmentId: z.string().optional(),
|
||||
@@ -340,16 +340,14 @@ export const ActionSchemas = {
|
||||
template: uuid.optional(),
|
||||
subscribed: z.boolean().optional().default(false),
|
||||
name: z.string().optional(),
|
||||
from: z
|
||||
.union([
|
||||
email, // Simple email string (backward compatible)
|
||||
z.object({
|
||||
// Object with name and email
|
||||
name: z.string().optional(),
|
||||
email: email,
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
from: z.union([
|
||||
email, // Simple email string (backward compatible)
|
||||
z.object({
|
||||
// Object with name and email
|
||||
name: z.string().optional(),
|
||||
email: email,
|
||||
}),
|
||||
]),
|
||||
reply: email.optional(),
|
||||
headers: z.record(z.string().max(998)).optional(),
|
||||
data: jsonSchema.optional(),
|
||||
|
||||
@@ -55,3 +55,40 @@ export interface SegmentMembershipComputeResult {
|
||||
removed: 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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1"
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -57,6 +57,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"recharts": "^3.5.1",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Image from 'next/image';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
export interface LoaderProps {
|
||||
message?: string;
|
||||
@@ -9,6 +11,28 @@ export interface LoaderProps {
|
||||
* Full-screen loader component with animated logo and spinner
|
||||
*/
|
||||
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 (
|
||||
<div className="h-screen flex items-center justify-center bg-white">
|
||||
<div className="text-center animate-in fade-in duration-500">
|
||||
@@ -48,6 +72,19 @@ export function Loader({message = 'Loading...', showLogo = true}: LoaderProps) {
|
||||
|
||||
{/* Loading message */}
|
||||
{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>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user