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 |
@@ -112,6 +112,25 @@ SMTP_DOMAIN=smtp.example.com
|
|||||||
# Maximum recipients per email (default: 5)
|
# Maximum recipients per email (default: 5)
|
||||||
# MAX_RECIPIENTS=5
|
# MAX_RECIPIENTS=5
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# OPTIONAL: Platform emails
|
||||||
|
# ========================================
|
||||||
|
# Your Plunk instance will send emails if you provide a Plunk API key and a from address
|
||||||
|
# These emails include system notifications, for example when your project hits billing limits
|
||||||
|
|
||||||
|
# PLUNK_API_KEY=
|
||||||
|
# PLUNK_FROM_ADDRESS=
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# OPTIONAL: Security Settings
|
||||||
|
# ========================================
|
||||||
|
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
|
||||||
|
# When enabled (default), projects exceeding security limits will be automatically suspended
|
||||||
|
# When disabled, violations will be logged and notifications sent, but projects won't be auto-disabled
|
||||||
|
# Recommended for self-hosters: false (manage project status manually)
|
||||||
|
# Default: true
|
||||||
|
# AUTO_PROJECT_DISABLE=false
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# ADVANCED (rarely needed)
|
# ADVANCED (rarely needed)
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.1.1"
|
".": "0.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.2.0](https://github.com/useplunk/plunk/compare/v0.1.1...v0.2.0) (2025-12-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* ability to create new campaigns based on templates or previous campaigns ([6b25bbe](https://github.com/useplunk/plunk/commit/6b25bbe2f86e5dd6946ea38a9f34e9c7bdb5fb0f))
|
||||||
|
* Add improved html editor using CodeMirror ([672f1e6](https://github.com/useplunk/plunk/commit/672f1e6657293860554be1f86167cf4f4403b0ff))
|
||||||
|
* Add security center and warning for exceeding bounce/complaint rates ([fbd3038](https://github.com/useplunk/plunk/commit/fbd303801f9f1c260c5f8201bf218c9f277fe4d1))
|
||||||
|
* Added platform emails for billing limits and disabled projects ([2485d2f](https://github.com/useplunk/plunk/commit/2485d2ff1db652e87f8f1307c8ef770edd19bbd1))
|
||||||
|
* Automatically detect rate limit from AWS with ability to override in .env ([3225c50](https://github.com/useplunk/plunk/commit/3225c5005be42f1443fdca0f8233193ce029c890))
|
||||||
|
* Improved createdAt and updatedAt visualisation ([1019ba0](https://github.com/useplunk/plunk/commit/1019ba0d82c7cb6a0d4cef5989841ccc28dae776))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* ability to clear reply-to and from name from templates and campaigns ([badb035](https://github.com/useplunk/plunk/commit/badb035585561b276b6e82a363693918810c8214))
|
||||||
|
* Add additional checks for disabled projects ([863e784](https://github.com/useplunk/plunk/commit/863e784e1c806118204acb3ba489322fe51498ad))
|
||||||
|
* Add additional checks for disabled projects ([780741e](https://github.com/useplunk/plunk/commit/780741e37f7fec5b822b91c329ebe98428077ba0))
|
||||||
|
* add additional indexes on event model ([1cd89d1](https://github.com/useplunk/plunk/commit/1cd89d137511ed4a8ae932a10f4f21487fbcee7c))
|
||||||
|
* Allow changing audience type after creation of campaign ([85b9d7a](https://github.com/useplunk/plunk/commit/85b9d7afe718c803be147475d5fbe13dafd677bf))
|
||||||
|
* Better highlight warnings in SecurityWarningBanner.tsx ([91eb0f3](https://github.com/useplunk/plunk/commit/91eb0f3a699eb7a51e87addfa122107ce74b4a39))
|
||||||
|
* Clear notification cache keys when changing billing limits ([e50c33a](https://github.com/useplunk/plunk/commit/e50c33ae4b2d5144a1bfce72ae97d8ede0187d17))
|
||||||
|
* Correctly show recipient count during creation and edit ([d0191be](https://github.com/useplunk/plunk/commit/d0191be31da8fc894269851a247746ce39a958b2))
|
||||||
|
* custom relative time to shorten strings for better UI fit ([5e4adb7](https://github.com/useplunk/plunk/commit/5e4adb71ade38cf53e67776ae3524a381641fcfd))
|
||||||
|
* display email progress instead of scheduling progress for campaigns ([07f875c](https://github.com/useplunk/plunk/commit/07f875c18c03a8d1766040bc40034c1023715fcd))
|
||||||
|
* Hide upsell banner if billing is not configured ([433e796](https://github.com/useplunk/plunk/commit/433e796c041e8d65068084d5c7729c397956098e))
|
||||||
|
* Increase z-index of color picker ([769e374](https://github.com/useplunk/plunk/commit/769e3748a0bbbcafdb1f79c9aea22d282e39b513))
|
||||||
|
* Move react and react-dom to dependencies instead of peerDependency for API ([a555a12](https://github.com/useplunk/plunk/commit/a555a127366f753130c765f9eb4700dc44a8cb7c))
|
||||||
|
* Move react and react-dom to dependencies instead of peerDependency for API ([f37bfcc](https://github.com/useplunk/plunk/commit/f37bfccbc22a0c2672971ff0e174f49f31301876))
|
||||||
|
* Only check free tier limits if billing is enabled ([1713b23](https://github.com/useplunk/plunk/commit/1713b2398d5c238058639ac5a0d5cef916a7a1e8))
|
||||||
|
* Only fetch project members if project Id is defined ([5a0a41c](https://github.com/useplunk/plunk/commit/5a0a41c6bbbb9bd4eefd5ed62e02c0a7c00c1022))
|
||||||
|
* Overflow of inputs in email editor ([3336fa1](https://github.com/useplunk/plunk/commit/3336fa1e38a29d9c1f69c62259662b1ff2ba6e61))
|
||||||
|
* Prevent manual tracking of internal events that are automatically tracked ([f34052e](https://github.com/useplunk/plunk/commit/f34052ed73cd78db4e4bf39cab8740ce0b78e93c))
|
||||||
|
* prevent scheduling of campaign if billing limit reached ([a3bbb1e](https://github.com/useplunk/plunk/commit/a3bbb1ed64ba461cb2f7170c1929f68f22b5bb4d))
|
||||||
|
* show correct default value for placeholder ([04275e3](https://github.com/useplunk/plunk/commit/04275e3cf59f902d50acb84e756f1c7425171726))
|
||||||
|
* Unauthenticated users are redirected to login on subscribe/unsubscribe/manage pages ([11ef47b](https://github.com/useplunk/plunk/commit/11ef47b576ec39209f2b9726c1027d06f8bbc03a))
|
||||||
|
* Update recipient count on create/update of campaign ([ff5c79c](https://github.com/useplunk/plunk/commit/ff5c79cfcf0ac6b351af20345ec639110f206f4b))
|
||||||
|
* Verify if sending without tracking is possible in SESService ([68f9979](https://github.com/useplunk/plunk/commit/68f99798f8c5944c18b438329b961fdec955ecef))
|
||||||
|
|
||||||
## [0.1.1](https://github.com/useplunk/plunk/compare/v0.1.0...v0.1.1) (2025-12-08)
|
## [0.1.1](https://github.com/useplunk/plunk/compare/v0.1.0...v0.1.1) (2025-12-08)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ Required for builds and deployment (see turbo.json and .env.example):
|
|||||||
- Stripe (optional): `STRIPE_SK`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ONBOARDING`, `STRIPE_PRICE_EMAIL_USAGE`,
|
- Stripe (optional): `STRIPE_SK`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ONBOARDING`, `STRIPE_PRICE_EMAIL_USAGE`,
|
||||||
`STRIPE_METER_EVENT_NAME`
|
`STRIPE_METER_EVENT_NAME`
|
||||||
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
||||||
|
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
|
||||||
|
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
|
||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ AWS_SES_SECRET_ACCESS_KEY=
|
|||||||
SES_CONFIGURATION_SET=plunk-configuration-set # Default: with open/click tracking
|
SES_CONFIGURATION_SET=plunk-configuration-set # Default: with open/click tracking
|
||||||
SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optional: without tracking (enables toggle in UI)
|
SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optional: without tracking (enables toggle in UI)
|
||||||
|
|
||||||
|
# Email sending rate limit (emails per second)
|
||||||
|
# If not set, automatically fetches from AWS SES account quota (recommended)
|
||||||
|
# Set this to override AWS quota (useful for setting lower limits or testing)
|
||||||
|
# Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts)
|
||||||
|
# EMAIL_RATE_LIMIT_PER_SECOND=14
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# OAuth (Optional - for social login)
|
# OAuth (Optional - for social login)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
@@ -70,3 +76,11 @@ STRIPE_WEBHOOK_SECRET=
|
|||||||
STRIPE_PRICE_ONBOARDING= # Optional: One-time onboarding fee price ID (e.g., price_xxxxx)
|
STRIPE_PRICE_ONBOARDING= # Optional: One-time onboarding fee price ID (e.g., price_xxxxx)
|
||||||
STRIPE_PRICE_EMAIL_USAGE= # Required: Metered price ID for pay-per-email billing
|
STRIPE_PRICE_EMAIL_USAGE= # Required: Metered price ID for pay-per-email billing
|
||||||
STRIPE_METER_EVENT_NAME=emails # Meter event name (API key from your Stripe meter, default: emails)
|
STRIPE_METER_EVENT_NAME=emails # Meter event name (API key from your Stripe meter, default: emails)
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Security (Optional)
|
||||||
|
# ==============================================================================
|
||||||
|
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
|
||||||
|
# Set to 'false' to disable automatic project suspension (useful for self-hosters who manage manually)
|
||||||
|
# Default: true (automatic project disabling enabled)
|
||||||
|
# AUTO_PROJECT_DISABLE=true
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
it('should validate subject and body required when no template', () => {
|
it('should validate subject and body required when no template', () => {
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: '[email protected]',
|
to: '[email protected]',
|
||||||
|
from: '[email protected]',
|
||||||
// Missing subject, body, and template
|
// Missing subject, body, and template
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,6 +170,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
it('should accept to as string (backward compatible)', () => {
|
it('should accept to as string (backward compatible)', () => {
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: '[email protected]',
|
to: '[email protected]',
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
});
|
});
|
||||||
@@ -182,6 +184,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
name: 'Jane Doe',
|
name: 'Jane Doe',
|
||||||
email: '[email protected]',
|
email: '[email protected]',
|
||||||
},
|
},
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
});
|
});
|
||||||
@@ -194,6 +197,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
to: {
|
to: {
|
||||||
email: '[email protected]',
|
email: '[email protected]',
|
||||||
},
|
},
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
});
|
});
|
||||||
@@ -204,6 +208,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
it('should accept to as array of strings', () => {
|
it('should accept to as array of strings', () => {
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: ['[email protected]', '[email protected]'],
|
to: ['[email protected]', '[email protected]'],
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
});
|
});
|
||||||
@@ -217,6 +222,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
{name: 'Jane Doe', email: '[email protected]'},
|
{name: 'Jane Doe', email: '[email protected]'},
|
||||||
{name: 'John Smith', email: '[email protected]'},
|
{name: 'John Smith', email: '[email protected]'},
|
||||||
],
|
],
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
});
|
});
|
||||||
@@ -227,6 +233,7 @@ describe('Actions API Integration Tests', () => {
|
|||||||
it('should accept to as mixed array of strings and objects', () => {
|
it('should accept to as mixed array of strings and objects', () => {
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: ['[email protected]', {name: 'John Smith', email: '[email protected]'}],
|
to: ['[email protected]', {name: 'John Smith', email: '[email protected]'}],
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
});
|
});
|
||||||
@@ -442,4 +449,288 @@ describe('Actions API Integration Tests', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// RESERVED EVENT VALIDATION
|
||||||
|
// ========================================
|
||||||
|
describe('Reserved Event Validation', () => {
|
||||||
|
describe('Email events (email.*)', () => {
|
||||||
|
it('should reject email.sent event', () => {
|
||||||
|
const result = ActionSchemas.track.safeParse({
|
||||||
|
event: 'email.sent',
|
||||||
|
email: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema allows it, but controller validation should reject
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify the error would be thrown by controller
|
||||||
|
const error = new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: 'Event name "email.sent" is reserved for system use and cannot be manually tracked',
|
||||||
|
code: 'reserved_event',
|
||||||
|
received: 'email.sent',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.code).toBe(422);
|
||||||
|
expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR);
|
||||||
|
expect(error.errors[0]?.code).toBe('reserved_event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject email.delivery event', () => {
|
||||||
|
const error = new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: 'Event name "email.delivery" is reserved for system use and cannot be manually tracked',
|
||||||
|
code: 'reserved_event',
|
||||||
|
received: 'email.delivery',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.code).toBe(422);
|
||||||
|
expect(error.errors[0]?.field).toBe('event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject email.open event', () => {
|
||||||
|
const error = new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: 'Event name "email.open" is reserved for system use and cannot be manually tracked',
|
||||||
|
code: 'reserved_event',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.code).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject email.click event', () => {
|
||||||
|
const error = new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: 'Event name "email.click" is reserved for system use and cannot be manually tracked',
|
||||||
|
code: 'reserved_event',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.code).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject email.bounce event', () => {
|
||||||
|
const error = new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: 'Event name "email.bounce" is reserved for system use and cannot be manually tracked',
|
||||||
|
code: 'reserved_event',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.code).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject email.complaint event', () => {
|
||||||
|
const error = new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: 'Event name "email.complaint" is reserved for system use and cannot be manually tracked',
|
||||||
|
code: 'reserved_event',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.code).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject any email.* pattern', () => {
|
||||||
|
const error = new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: 'Event name "email.custom" is reserved for system use and cannot be manually tracked',
|
||||||
|
code: 'reserved_event',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.code).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Contact events', () => {
|
||||||
|
it('should reject contact.subscribed event', () => {
|
||||||
|
const error = new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: 'Event name "contact.subscribed" is reserved for system use and cannot be manually tracked',
|
||||||
|
code: 'reserved_event',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.code).toBe(422);
|
||||||
|
expect(error.errors[0]?.field).toBe('event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject contact.unsubscribed event', () => {
|
||||||
|
const error = new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: 'Event name "contact.unsubscribed" is reserved for system use and cannot be manually tracked',
|
||||||
|
code: 'reserved_event',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.code).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow other contact.* events', () => {
|
||||||
|
const result1 = ActionSchemas.track.safeParse({
|
||||||
|
event: 'contact.created',
|
||||||
|
email: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result2 = ActionSchemas.track.safeParse({
|
||||||
|
event: 'contact.updated',
|
||||||
|
email: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result1.success).toBe(true);
|
||||||
|
expect(result2.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Segment events', () => {
|
||||||
|
it('should reject segment.*.entry events', () => {
|
||||||
|
const error = new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: 'Event name "segment.vip-users.entry" is reserved for system use and cannot be manually tracked',
|
||||||
|
code: 'reserved_event',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.code).toBe(422);
|
||||||
|
expect(error.errors[0]?.code).toBe('reserved_event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject segment.*.exit events', () => {
|
||||||
|
const error = new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: 'Event name "segment.premium.exit" is reserved for system use and cannot be manually tracked',
|
||||||
|
code: 'reserved_event',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.code).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow other segment.* events', () => {
|
||||||
|
const result1 = ActionSchemas.track.safeParse({
|
||||||
|
event: 'segment.created',
|
||||||
|
email: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result2 = ActionSchemas.track.safeParse({
|
||||||
|
event: 'segment.premium.updated',
|
||||||
|
email: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result1.success).toBe(true);
|
||||||
|
expect(result2.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom user events', () => {
|
||||||
|
it('should allow custom user events', () => {
|
||||||
|
const testCases = [
|
||||||
|
'user.signup',
|
||||||
|
'purchase.completed',
|
||||||
|
'order.placed',
|
||||||
|
'custom.event',
|
||||||
|
'product.viewed',
|
||||||
|
'cart.abandoned',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const eventName of testCases) {
|
||||||
|
const result = ActionSchemas.track.safeParse({
|
||||||
|
event: eventName,
|
||||||
|
email: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow events with similar but different prefixes', () => {
|
||||||
|
const testCases = ['emails.sent', 'contacts.subscribed', 'segments.entry'];
|
||||||
|
|
||||||
|
for (const eventName of testCases) {
|
||||||
|
const result = ActionSchemas.track.safeParse({
|
||||||
|
event: eventName,
|
||||||
|
email: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error structure for reserved events', () => {
|
||||||
|
it('should return ValidationError with correct structure', () => {
|
||||||
|
const error = new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: 'Event name "email.sent" is reserved for system use and cannot be manually tracked',
|
||||||
|
code: 'reserved_event',
|
||||||
|
received: 'email.sent',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(ValidationError);
|
||||||
|
expect(error.code).toBe(422);
|
||||||
|
expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR);
|
||||||
|
expect(error.message).toBe('Cannot track reserved system event');
|
||||||
|
expect(error.errors).toHaveLength(1);
|
||||||
|
expect(error.errors[0]).toMatchObject({
|
||||||
|
field: 'event',
|
||||||
|
code: 'reserved_event',
|
||||||
|
received: 'email.sent',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-2
@@ -14,12 +14,13 @@ import {
|
|||||||
GOOGLE_OAUTH_ENABLED,
|
GOOGLE_OAUTH_ENABLED,
|
||||||
LANDING_URI,
|
LANDING_URI,
|
||||||
NODE_ENV,
|
NODE_ENV,
|
||||||
|
PLUNK_ENABLED,
|
||||||
PORT,
|
PORT,
|
||||||
S3_ENABLED,
|
S3_ENABLED,
|
||||||
SMTP_ENABLED,
|
SMTP_ENABLED,
|
||||||
STRIPE_ENABLED,
|
STRIPE_ENABLED,
|
||||||
TRACKING_TOGGLE_ENABLED,
|
TRACKING_TOGGLE_ENABLED,
|
||||||
WIKI_URI,
|
WIKI_URI
|
||||||
} from './app/constants.js';
|
} from './app/constants.js';
|
||||||
import {Actions} from './controllers/Actions.js';
|
import {Actions} from './controllers/Actions.js';
|
||||||
import {Activity} from './controllers/Activity.js';
|
import {Activity} from './controllers/Activity.js';
|
||||||
@@ -309,7 +310,7 @@ server.app.use((error: Error, req: Request, res: Response, _next: NextFunction)
|
|||||||
// Global error handlers to prevent server crashes
|
// Global error handlers to prevent server crashes
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
signale.error('Unhandled Promise Rejection:', reason);
|
signale.error('Unhandled Promise Rejection:', reason);
|
||||||
console.error('Promise:', promise);
|
signale.error('Promise:', promise);
|
||||||
// Don't exit the process - just log the error
|
// Don't exit the process - just log the error
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -351,6 +352,11 @@ void prisma.$connect().then(async () => {
|
|||||||
? 'Per-project tracking toggle enabled'
|
? 'Per-project tracking toggle enabled'
|
||||||
: 'Always tracking or always no-tracking',
|
: 'Always tracking or always no-tracking',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Platform emails',
|
||||||
|
enabled: PLUNK_ENABLED,
|
||||||
|
details: PLUNK_ENABLED ? 'Platform email notifications enabled' : 'PLUNK_API_KEY not configured',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = features.map(f => ({
|
const rows = features.map(f => ({
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
|
|||||||
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
|
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
|
||||||
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
|
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
|
||||||
|
|
||||||
|
// Email Processing Rate Limit (optional override)
|
||||||
|
// If not set, will automatically fetch from AWS SES account quota
|
||||||
|
// Set this to override AWS quota (useful for setting lower limits or testing)
|
||||||
|
export const EMAIL_RATE_LIMIT_PER_SECOND = process.env.EMAIL_RATE_LIMIT_PER_SECOND
|
||||||
|
? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
export const REDIS_URL = validateEnv('REDIS_URL');
|
export const REDIS_URL = validateEnv('REDIS_URL');
|
||||||
export const DATABASE_URL = validateEnv('DATABASE_URL');
|
export const DATABASE_URL = validateEnv('DATABASE_URL');
|
||||||
@@ -87,3 +94,12 @@ export const SMTP_PORT_SUBMISSION = Number(validateEnv('PORT_SUBMISSION', '587')
|
|||||||
// Enable SMTP features only when explicitly enabled via env or when a non-default domain is configured
|
// Enable SMTP features only when explicitly enabled via env or when a non-default domain is configured
|
||||||
export const SMTP_ENABLED =
|
export const SMTP_ENABLED =
|
||||||
process.env.SMTP_ENABLED === 'true' || (SMTP_DOMAIN !== 'localhost' && NODE_ENV !== 'development');
|
process.env.SMTP_ENABLED === 'true' || (SMTP_DOMAIN !== 'localhost' && NODE_ENV !== 'development');
|
||||||
|
|
||||||
|
export const PLUNK_API_KEY = validateEnv('PLUNK_API_KEY', '');
|
||||||
|
export const PLUNK_FROM_ADDRESS = validateEnv('PLUNK_FROM_ADDRESS', '');
|
||||||
|
export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
|
||||||
|
|
||||||
|
// Security (optional)
|
||||||
|
// Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
|
||||||
|
// Useful for self-hosters who want to manage project status manually
|
||||||
|
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {ContactService} from '../services/ContactService.js';
|
|||||||
import {DomainService} from '../services/DomainService.js';
|
import {DomainService} from '../services/DomainService.js';
|
||||||
import {EmailService} from '../services/EmailService.js';
|
import {EmailService} from '../services/EmailService.js';
|
||||||
import {EventService} from '../services/EventService.js';
|
import {EventService} from '../services/EventService.js';
|
||||||
import {NotFound} from '../exceptions/index.js';
|
import {NotFound, ValidationError} from '../exceptions/index.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +55,21 @@ export class Actions {
|
|||||||
// Zod validation - errors automatically handled by global error handler
|
// Zod validation - errors automatically handled by global error handler
|
||||||
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
|
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
|
||||||
|
|
||||||
|
// Prevent manual tracking of reserved system events
|
||||||
|
if (EventService.isReservedEvent(event)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'event',
|
||||||
|
message: `Event name "${event}" is reserved for system use and cannot be manually tracked`,
|
||||||
|
code: 'reserved_event',
|
||||||
|
received: event,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Cannot track reserved system event',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Create or update contact with persistent data only
|
// Create or update contact with persistent data only
|
||||||
// ContactService.upsert will filter out non-persistent fields
|
// ContactService.upsert will filter out non-persistent fields
|
||||||
const contact = await ContactService.upsert(
|
const contact = await ContactService.upsert(
|
||||||
@@ -223,14 +238,21 @@ export class Actions {
|
|||||||
templateId = templateRecord.id;
|
templateId = templateRecord.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify 'from' domain is verified if provided
|
if (!emailFrom) {
|
||||||
const senderEmail = emailFrom || '[email protected]'; // Default sender
|
throw new ValidationError(
|
||||||
|
[
|
||||||
// Only verify custom domains (not the default [email protected])
|
{
|
||||||
if (emailFrom && emailFrom !== '[email protected]') {
|
field: 'from',
|
||||||
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
|
message: 'Sender email is required either in request or template',
|
||||||
|
code: 'required',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Could not parse sender email',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
|
||||||
|
|
||||||
const replyToEmail = emailReplyTo;
|
const replyToEmail = emailReplyTo;
|
||||||
|
|
||||||
const timestamp = new Date();
|
const timestamp = new Date();
|
||||||
@@ -281,7 +303,7 @@ export class Actions {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
subject: renderedSubject,
|
subject: renderedSubject,
|
||||||
body: renderedBody,
|
body: renderedBody,
|
||||||
from: senderEmail,
|
from: emailFrom,
|
||||||
fromName: emailFromName,
|
fromName: emailFromName,
|
||||||
toName: recipient.name,
|
toName: recipient.name,
|
||||||
replyTo: replyToEmail,
|
replyTo: replyToEmail,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
||||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||||
import {CampaignSchemas} from '@plunk/shared';
|
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
@@ -97,7 +97,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async get(req: Request, res: Response, _next: NextFunction) {
|
private async get(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const campaign = await CampaignService.get(auth.projectId, id!);
|
const campaign = await CampaignService.get(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async update(req: Request, res: Response, _next: NextFunction) {
|
private async update(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
||||||
req.body;
|
req.body;
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async delete(req: Request, res: Response, _next: NextFunction) {
|
private async delete(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
await CampaignService.delete(auth.projectId, id!);
|
await CampaignService.delete(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const campaign = await CampaignService.duplicate(auth.projectId, id!);
|
const campaign = await CampaignService.duplicate(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async send(req: Request, res: Response, _next: NextFunction) {
|
private async send(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const scheduledFor = req.body?.scheduledFor;
|
const scheduledFor = req.body?.scheduledFor;
|
||||||
|
|
||||||
// Parse scheduledFor if provided
|
// Parse scheduledFor if provided
|
||||||
@@ -232,7 +232,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const campaign = await CampaignService.cancel(auth.projectId, id!);
|
const campaign = await CampaignService.cancel(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async stats(req: Request, res: Response, _next: NextFunction) {
|
private async stats(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const stats = await CampaignService.getStats(auth.projectId, id!);
|
const stats = await CampaignService.getStats(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const {email} = CampaignSchemas.sendTest.parse(req.body);
|
const {email} = CampaignSchemas.sendTest.parse(req.body);
|
||||||
|
|
||||||
await CampaignService.sendTest(auth.projectId, id!, email);
|
await CampaignService.sendTest(auth.projectId, id!, email);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {requireAuth} from '../middleware/auth.js';
|
import {requireAuth} from '../middleware/auth.js';
|
||||||
@@ -63,7 +64,7 @@ export class Contacts {
|
|||||||
count: fieldsWithTypes.length,
|
count: fieldsWithTypes.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CONTACTS] Failed to get available fields:', error);
|
signale.error('[CONTACTS] Failed to get available fields:', error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
||||||
});
|
});
|
||||||
@@ -97,7 +98,7 @@ export class Contacts {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CONTACTS] Failed to get field values:', error);
|
signale.error('[CONTACTS] Failed to get field values:', error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to get field values',
|
error: error instanceof Error ? error.message : 'Failed to get field values',
|
||||||
});
|
});
|
||||||
@@ -288,7 +289,7 @@ export class Contacts {
|
|||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CONTACTS] Failed to queue import:', error);
|
signale.error('[CONTACTS] Failed to queue import:', error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to queue import',
|
error: error instanceof Error ? error.message : 'Failed to queue import',
|
||||||
});
|
});
|
||||||
@@ -318,7 +319,7 @@ export class Contacts {
|
|||||||
|
|
||||||
return res.status(200).json(status);
|
return res.status(200).json(status);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CONTACTS] Failed to get import status:', error);
|
signale.error('[CONTACTS] Failed to get import status:', error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to get import status',
|
error: error instanceof Error ? error.message : 'Failed to get import status',
|
||||||
});
|
});
|
||||||
@@ -345,7 +346,7 @@ export class Contacts {
|
|||||||
const usage = await ContactService.getFieldUsage(auth.projectId!, field);
|
const usage = await ContactService.getFieldUsage(auth.projectId!, field);
|
||||||
return res.status(200).json(usage);
|
return res.status(200).json(usage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CONTACTS] Failed to get field usage:', error);
|
signale.error('[CONTACTS] Failed to get field usage:', error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to get field usage',
|
error: error instanceof Error ? error.message : 'Failed to get field usage',
|
||||||
});
|
});
|
||||||
@@ -372,7 +373,7 @@ export class Contacts {
|
|||||||
const result = await ContactService.deleteField(auth.projectId!, field);
|
const result = await ContactService.deleteField(auth.projectId!, field);
|
||||||
return res.status(200).json(result);
|
return res.status(200).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CONTACTS] Failed to delete field:', error);
|
signale.error('[CONTACTS] Failed to delete field:', error);
|
||||||
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to delete field',
|
error: error instanceof Error ? error.message : 'Failed to delete field',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {requireAuth} from '../middleware/auth.js';
|
import {requireAuth} from '../middleware/auth.js';
|
||||||
@@ -118,7 +119,7 @@ export class Events {
|
|||||||
const usage = await EventService.getEventUsage(auth.projectId!, eventName);
|
const usage = await EventService.getEventUsage(auth.projectId!, eventName);
|
||||||
return res.status(200).json(usage);
|
return res.status(200).json(usage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[EVENTS] Failed to get event usage:', error);
|
signale.error('[EVENTS] Failed to get event usage:', error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to get event usage',
|
error: error instanceof Error ? error.message : 'Failed to get event usage',
|
||||||
});
|
});
|
||||||
@@ -145,7 +146,7 @@ export class Events {
|
|||||||
const result = await EventService.deleteEvent(auth.projectId!, eventName);
|
const result = await EventService.deleteEvent(auth.projectId!, eventName);
|
||||||
return res.status(200).json(result);
|
return res.status(200).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[EVENTS] Failed to delete event:', error);
|
signale.error('[EVENTS] Failed to delete event:', error);
|
||||||
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to delete event',
|
error: error instanceof Error ? error.message : 'Failed to delete event',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import {MembershipSchemas} from '@plunk/shared';
|
import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {requireAuth} from '../middleware/auth.js';
|
import {requireAuth} from '../middleware/auth.js';
|
||||||
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
@Controller('projects')
|
@Controller('projects')
|
||||||
@@ -19,7 +20,7 @@ export class Projects {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
const membership = await prisma.membership.findFirst({
|
||||||
@@ -83,6 +84,38 @@ export class Projects {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project security metrics
|
||||||
|
* GET /projects/:id/security
|
||||||
|
*/
|
||||||
|
@Get(':id/security')
|
||||||
|
@Middleware([requireAuth])
|
||||||
|
@CatchAsync
|
||||||
|
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth as AuthResponse;
|
||||||
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
|
// Verify user has access to this project
|
||||||
|
const membership = await prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: auth.userId,
|
||||||
|
projectId: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new HttpException(404, 'Project not found or you do not have access');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing SecurityService
|
||||||
|
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: metrics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all members of a project
|
* Get all members of a project
|
||||||
* GET /projects/:id/members
|
* GET /projects/:id/members
|
||||||
@@ -92,7 +125,7 @@ export class Projects {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
const membership = await prisma.membership.findFirst({
|
||||||
@@ -141,7 +174,7 @@ export class Projects {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Validate params
|
// Validate params
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Controller, Middleware, Post} from '@overnightjs/core';
|
import {Controller, Middleware, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {requireAuth} from '../middleware/auth.js';
|
import {requireAuth} from '../middleware/auth.js';
|
||||||
@@ -66,7 +67,7 @@ export class Uploads {
|
|||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[UPLOADS] Failed to upload image:', error);
|
signale.error('[UPLOADS] Failed to upload image:', error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to upload image',
|
error: error instanceof Error ? error.message : 'Failed to upload image',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {randomBytes} from 'node:crypto';
|
import {randomBytes} from 'node:crypto';
|
||||||
|
|
||||||
import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
|
||||||
import {BillingLimitSchemas, ProjectSchemas} from '@plunk/shared';
|
import {BillingLimitSchemas, ProjectSchemas, UtilitySchemas} from '@plunk/shared';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
|
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
|
||||||
import {stripe} from '../app/stripe.js';
|
import {stripe} from '../app/stripe.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {NotAuthenticated, NotFound} from '../exceptions/index.js';
|
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {isAuthenticated} from '../middleware/auth.js';
|
import {isAuthenticated} from '../middleware/auth.js';
|
||||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||||
@@ -63,6 +63,16 @@ export class Users {
|
|||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user is a member of any disabled project
|
||||||
|
const {hasDisabledProject, disabledProjectNames} = await SecurityService.userHasDisabledProject(auth.userId);
|
||||||
|
if (hasDisabledProject) {
|
||||||
|
throw new HttpException(
|
||||||
|
403,
|
||||||
|
`You cannot create new projects while you are a member of disabled projects: ${disabledProjectNames.join(', ')}. Please contact support to resolve security violations.`,
|
||||||
|
ErrorCode.PROJECT_DISABLED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const {name} = ProjectSchemas.create.parse(req.body);
|
const {name} = ProjectSchemas.create.parse(req.body);
|
||||||
|
|
||||||
// Generate unique API keys
|
// Generate unique API keys
|
||||||
@@ -95,7 +105,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const data = ProjectSchemas.update.parse(req.body);
|
const data = ProjectSchemas.update.parse(req.body);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
@@ -127,7 +137,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has admin/owner access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
const membership = await prisma.membership.findFirst({
|
||||||
@@ -179,7 +189,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -258,7 +268,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -308,7 +318,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -341,7 +351,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -368,11 +378,14 @@ export class Users {
|
|||||||
throw new NotFound('Project not found or you do not have permission to update billing limits');
|
throw new NotFound('Project not found or you do not have permission to update billing limits');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the project
|
// Get the project with current limits
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: {id},
|
where: {id},
|
||||||
select: {
|
select: {
|
||||||
subscription: true,
|
subscription: true,
|
||||||
|
billingLimitWorkflows: true,
|
||||||
|
billingLimitCampaigns: true,
|
||||||
|
billingLimitTransactional: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -395,8 +408,17 @@ export class Users {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalidate cache so new limits take effect immediately
|
// Clear notification cache keys for limits that changed
|
||||||
await BillingLimitService.invalidateCache(id);
|
// This allows new warning/limit emails to be sent when the new limits are reached
|
||||||
|
await BillingLimitService.clearNotificationCacheForChangedLimits(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
workflows: project.billingLimitWorkflows,
|
||||||
|
campaigns: project.billingLimitCampaigns,
|
||||||
|
transactional: project.billingLimitTransactional,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
||||||
|
|
||||||
@@ -408,7 +430,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -534,7 +556,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -622,7 +644,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -655,7 +677,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -680,6 +702,16 @@ export class Users {
|
|||||||
throw new NotFound('Project not found or you do not have permission to reset it');
|
throw new NotFound('Project not found or you do not have permission to reset it');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if project is disabled - block reset operation
|
||||||
|
const isDisabled = await SecurityService.isProjectDisabled(id);
|
||||||
|
if (isDisabled) {
|
||||||
|
throw new HttpException(
|
||||||
|
403,
|
||||||
|
'Cannot reset a disabled project. Please contact support to resolve security violations before making changes.',
|
||||||
|
ErrorCode.PROJECT_DISABLED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete all project data in a transaction
|
// Delete all project data in a transaction
|
||||||
await prisma.$transaction(async tx => {
|
await prisma.$transaction(async tx => {
|
||||||
// Delete all emails
|
// Delete all emails
|
||||||
@@ -731,7 +763,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -758,13 +790,14 @@ export class Users {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get project to check for active subscription
|
// Get project to check for active subscription and disabled status
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: {id},
|
where: {id},
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
subscription: true,
|
subscription: true,
|
||||||
customer: true,
|
customer: true,
|
||||||
|
disabled: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -772,6 +805,15 @@ export class Users {
|
|||||||
throw new NotFound('Project not found');
|
throw new NotFound('Project not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if project is disabled - block delete operation
|
||||||
|
if (project.disabled) {
|
||||||
|
throw new HttpException(
|
||||||
|
403,
|
||||||
|
'Cannot delete a disabled project. Please contact support to resolve security violations.',
|
||||||
|
ErrorCode.PROJECT_DISABLED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// If project has an active subscription, cancel it first
|
// If project has an active subscription, cancel it first
|
||||||
if (STRIPE_ENABLED && stripe && project.subscription) {
|
if (STRIPE_ENABLED && stripe && project.subscription) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||||
import {WorkflowExecutionStatus} from '@plunk/db';
|
import {WorkflowExecutionStatus} from '@plunk/db';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {requireAuth} from '../middleware/auth.js';
|
import {requireAuth} from '../middleware/auth.js';
|
||||||
@@ -45,7 +46,7 @@ export class Workflows {
|
|||||||
|
|
||||||
return res.status(200).json(result);
|
return res.status(200).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WORKFLOWS] Failed to get available fields:', error);
|
signale.error('[WORKFLOWS] Failed to get available fields:', error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {CampaignService} from '../services/CampaignService.js';
|
import {CampaignService} from '../services/CampaignService.js';
|
||||||
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
|
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
|
||||||
@@ -14,11 +15,11 @@ export function createCampaignWorker() {
|
|||||||
async (job: Job<CampaignBatchJobData>) => {
|
async (job: Job<CampaignBatchJobData>) => {
|
||||||
const {campaignId, batchNumber, offset, limit, cursor} = job.data;
|
const {campaignId, batchNumber, offset, limit, cursor} = job.data;
|
||||||
|
|
||||||
console.log(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
|
signale.info(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
|
||||||
|
|
||||||
await CampaignService.processBatch(campaignId, batchNumber, offset, limit, cursor);
|
await CampaignService.processBatch(campaignId, batchNumber, offset, limit, cursor);
|
||||||
|
|
||||||
console.log(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
|
signale.info(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connection: campaignQueue.opts.connection,
|
connection: campaignQueue.opts.connection,
|
||||||
@@ -27,15 +28,15 @@ export function createCampaignWorker() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
worker.on('completed', job => {
|
||||||
console.log(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
|
signale.info(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on('failed', (job, err) => {
|
worker.on('failed', (job, err) => {
|
||||||
console.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
signale.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on('error', err => {
|
worker.on('error', err => {
|
||||||
console.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
|
signale.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return worker;
|
return worker;
|
||||||
|
|||||||
@@ -3,18 +3,50 @@
|
|||||||
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
|
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {EmailService} from '../services/EmailService.js';
|
import {EmailService} from '../services/EmailService.js';
|
||||||
import {EventService} from '../services/EventService.js';
|
import {EventService} from '../services/EventService.js';
|
||||||
import {MeterService} from '../services/MeterService.js';
|
import {MeterService} from '../services/MeterService.js';
|
||||||
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
|
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
|
||||||
import {sendRawEmail} from '../services/SESService.js';
|
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
|
||||||
import {DASHBOARD_URI} from '../app/constants.js';
|
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
|
||||||
|
|
||||||
export function createEmailWorker() {
|
/**
|
||||||
|
* Determine the email sending rate limit (emails per second)
|
||||||
|
* Priority: ENV variable > AWS SES quota > Safe default (14)
|
||||||
|
*/
|
||||||
|
async function getEmailRateLimit(): Promise<number> {
|
||||||
|
const DEFAULT_RATE_LIMIT = 14; // AWS SES sandbox limit - safe default
|
||||||
|
|
||||||
|
// If env variable is set, use it (override)
|
||||||
|
if (EMAIL_RATE_LIMIT_PER_SECOND !== undefined) {
|
||||||
|
signale.info(`[EMAIL-PROCESSOR] Using rate limit from environment: ${EMAIL_RATE_LIMIT_PER_SECOND} emails/second`);
|
||||||
|
return EMAIL_RATE_LIMIT_PER_SECOND;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to fetch from AWS SES
|
||||||
|
signale.info('[EMAIL-PROCESSOR] Fetching rate limit from AWS SES...');
|
||||||
|
const quota = await getSendingQuota();
|
||||||
|
|
||||||
|
if (quota) {
|
||||||
|
signale.info(
|
||||||
|
`[EMAIL-PROCESSOR] AWS SES quota: ${quota.maxSendRate} emails/second (${quota.sentLast24Hours}/${quota.max24HourSend} emails sent today)`,
|
||||||
|
);
|
||||||
|
return quota.maxSendRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to safe default
|
||||||
|
signale.warn(`[EMAIL-PROCESSOR] Failed to fetch AWS quota, using safe default: ${DEFAULT_RATE_LIMIT} emails/second`);
|
||||||
|
return DEFAULT_RATE_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEmailWorker() {
|
||||||
|
// Fetch the rate limit (from env, AWS, or default)
|
||||||
|
const rateLimit = await getEmailRateLimit();
|
||||||
const worker = new Worker<SendEmailJobData>(
|
const worker = new Worker<SendEmailJobData>(
|
||||||
emailQueue.name,
|
emailQueue.name,
|
||||||
async (job: Job<SendEmailJobData>) => {
|
async (job: Job<SendEmailJobData>) => {
|
||||||
@@ -38,7 +70,7 @@ export function createEmailWorker() {
|
|||||||
|
|
||||||
// Check if project is disabled
|
// Check if project is disabled
|
||||||
if (email.project.disabled) {
|
if (email.project.disabled) {
|
||||||
console.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
|
signale.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
|
||||||
await prisma.email.update({
|
await prisma.email.update({
|
||||||
where: {id: emailId},
|
where: {id: emailId},
|
||||||
data: {
|
data: {
|
||||||
@@ -138,8 +170,59 @@ export function createEmailWorker() {
|
|||||||
sourceType: email.sourceType,
|
sourceType: email.sourceType,
|
||||||
sentAt: new Date().toISOString(),
|
sentAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If this email belongs to a campaign, check if all campaign emails have been sent
|
||||||
|
if (email.campaignId) {
|
||||||
|
const campaign = await prisma.campaign.findUnique({
|
||||||
|
where: {id: email.campaignId},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
totalRecipients: true,
|
||||||
|
projectId: true,
|
||||||
|
project: {
|
||||||
|
select: {name: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only check if campaign is still in SENDING status
|
||||||
|
if (campaign && campaign.status === CampaignStatus.SENDING) {
|
||||||
|
// Count how many emails have been sent for this campaign
|
||||||
|
const sentCount = await prisma.email.count({
|
||||||
|
where: {
|
||||||
|
campaignId: email.campaignId,
|
||||||
|
sentAt: {not: null},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If all emails have been sent, mark campaign as SENT
|
||||||
|
if (sentCount >= campaign.totalRecipients) {
|
||||||
|
await prisma.campaign.update({
|
||||||
|
where: {id: email.campaignId},
|
||||||
|
data: {
|
||||||
|
status: CampaignStatus.SENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
signale.success(
|
||||||
|
`[EMAIL-PROCESSOR] Campaign ${campaign.name} completed: ${sentCount}/${campaign.totalRecipients} emails sent`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send notification about campaign send completed
|
||||||
|
const {NtfyService} = await import('../services/NtfyService.js');
|
||||||
|
await NtfyService.notifyCampaignSendCompleted(
|
||||||
|
campaign.name,
|
||||||
|
campaign.project.name,
|
||||||
|
campaign.projectId,
|
||||||
|
campaign.totalRecipients,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
|
signale.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
|
||||||
|
|
||||||
// Mark as failed
|
// Mark as failed
|
||||||
await prisma.email.update({
|
await prisma.email.update({
|
||||||
@@ -157,22 +240,22 @@ export function createEmailWorker() {
|
|||||||
connection: emailQueue.opts.connection,
|
connection: emailQueue.opts.connection,
|
||||||
concurrency: 10, // Process up to 10 emails concurrently
|
concurrency: 10, // Process up to 10 emails concurrently
|
||||||
limiter: {
|
limiter: {
|
||||||
max: 25, // Max 25 emails per second
|
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
worker.on('completed', job => {
|
||||||
console.log(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
|
signale.info(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on('failed', (job, err) => {
|
worker.on('failed', (job, err) => {
|
||||||
console.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
signale.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on('error', err => {
|
worker.on('error', err => {
|
||||||
console.error('[EMAIL-PROCESSOR] Worker error:', err);
|
signale.error('[EMAIL-PROCESSOR] Worker error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return worker;
|
return worker;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
import {parse} from 'csv-parse/sync';
|
import {parse} from 'csv-parse/sync';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {ContactService} from '../services/ContactService.js';
|
import {ContactService} from '../services/ContactService.js';
|
||||||
@@ -28,7 +29,7 @@ export function createImportWorker() {
|
|||||||
async (job: Job<ContactImportJobData>) => {
|
async (job: Job<ContactImportJobData>) => {
|
||||||
const {projectId, csvData, filename} = job.data;
|
const {projectId, csvData, filename} = job.data;
|
||||||
|
|
||||||
console.log(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
|
signale.info(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
|
||||||
|
|
||||||
// Fetch project information for notifications
|
// Fetch project information for notifications
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -66,7 +67,7 @@ export function createImportWorker() {
|
|||||||
throw new Error('CSV file is empty');
|
throw new Error('CSV file is empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
|
signale.info(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
|
||||||
|
|
||||||
// Notify that import has started
|
// Notify that import has started
|
||||||
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
|
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
|
||||||
@@ -151,7 +152,7 @@ export function createImportWorker() {
|
|||||||
await job.updateProgress(progress);
|
await job.updateProgress(progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
signale.info(
|
||||||
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
|
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -168,7 +169,7 @@ export function createImportWorker() {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
|
signale.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
|
||||||
|
|
||||||
// Notify that import has failed
|
// Notify that import has failed
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -191,15 +192,15 @@ export function createImportWorker() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
worker.on('completed', job => {
|
||||||
console.log(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
|
signale.info(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on('failed', (job, err) => {
|
worker.on('failed', (job, err) => {
|
||||||
console.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
signale.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on('error', err => {
|
worker.on('error', err => {
|
||||||
console.error('[IMPORT-PROCESSOR] Worker error:', err);
|
signale.error('[IMPORT-PROCESSOR] Worker error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return worker;
|
return worker;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import {CampaignStatus} from '@plunk/db';
|
import {CampaignStatus} from '@plunk/db';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {CampaignService} from '../services/CampaignService.js';
|
import {CampaignService} from '../services/CampaignService.js';
|
||||||
@@ -16,7 +17,7 @@ export function createScheduledCampaignWorker() {
|
|||||||
async (job: Job<ScheduledCampaignJobData>) => {
|
async (job: Job<ScheduledCampaignJobData>) => {
|
||||||
const {campaignId} = job.data;
|
const {campaignId} = job.data;
|
||||||
|
|
||||||
console.log(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
|
signale.info(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
|
||||||
|
|
||||||
// Get campaign with project
|
// Get campaign with project
|
||||||
const campaign = await prisma.campaign.findUnique({
|
const campaign = await prisma.campaign.findUnique({
|
||||||
@@ -29,13 +30,13 @@ export function createScheduledCampaignWorker() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
console.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
|
signale.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if project is disabled
|
// Check if project is disabled
|
||||||
if (campaign.project.disabled) {
|
if (campaign.project.disabled) {
|
||||||
console.warn(
|
signale.warn(
|
||||||
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
|
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
|
||||||
);
|
);
|
||||||
await prisma.campaign.update({
|
await prisma.campaign.update({
|
||||||
@@ -47,7 +48,7 @@ export function createScheduledCampaignWorker() {
|
|||||||
|
|
||||||
// Verify campaign is still in SCHEDULED status
|
// Verify campaign is still in SCHEDULED status
|
||||||
if (campaign.status !== CampaignStatus.SCHEDULED) {
|
if (campaign.status !== CampaignStatus.SCHEDULED) {
|
||||||
console.warn(
|
signale.warn(
|
||||||
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
|
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -56,7 +57,7 @@ export function createScheduledCampaignWorker() {
|
|||||||
// Start sending the campaign
|
// Start sending the campaign
|
||||||
await CampaignService.startSending(campaign.projectId, campaignId);
|
await CampaignService.startSending(campaign.projectId, campaignId);
|
||||||
|
|
||||||
console.log(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
|
signale.info(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connection: scheduledQueue.opts.connection,
|
connection: scheduledQueue.opts.connection,
|
||||||
@@ -65,15 +66,15 @@ export function createScheduledCampaignWorker() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
worker.on('completed', job => {
|
||||||
console.log(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
|
signale.info(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on('failed', (job, err) => {
|
worker.on('failed', (job, err) => {
|
||||||
console.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
signale.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on('error', err => {
|
worker.on('error', err => {
|
||||||
console.error('[SCHEDULED-PROCESSOR] Worker error:', err);
|
signale.error('[SCHEDULED-PROCESSOR] Worker error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return worker;
|
return worker;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async function startWorkers() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Start email worker
|
// Start email worker
|
||||||
const emailWorker = createEmailWorker();
|
const emailWorker = await createEmailWorker();
|
||||||
workers.push({name: 'email', worker: emailWorker});
|
workers.push({name: 'email', worker: emailWorker});
|
||||||
signale.success('[WORKER] Email worker started');
|
signale.success('[WORKER] Email worker started');
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
|
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
|
||||||
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
|
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
|
||||||
@@ -33,15 +34,15 @@ export function createWorkflowWorker() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
worker.on('completed', job => {
|
||||||
console.log(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
|
signale.info(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on('failed', (job, err) => {
|
worker.on('failed', (job, err) => {
|
||||||
console.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
signale.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on('error', err => {
|
worker.on('error', err => {
|
||||||
console.error('[WORKFLOW-PROCESSOR] Worker error:', err);
|
signale.error('[WORKFLOW-PROCESSOR] Worker error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return worker;
|
return worker;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type {Prisma} from '@plunk/db';
|
import type {Prisma} from '@plunk/db';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {Keys} from './keys.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activity types that can be tracked
|
* Activity types that can be tracked
|
||||||
@@ -170,7 +172,7 @@ export class ActivityService {
|
|||||||
*/
|
*/
|
||||||
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
|
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
|
||||||
// Try to get from cache
|
// Try to get from cache
|
||||||
const cacheKey = `activity:stats:${projectId}:${startDate?.getTime() || 'all'}:${endDate?.getTime() || 'now'}`;
|
const cacheKey = Keys.Activity.stats(projectId, startDate?.getTime() || 'all', endDate?.getTime() || 'now');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cached = await redis.get(cacheKey);
|
const cached = await redis.get(cacheKey);
|
||||||
@@ -178,7 +180,7 @@ export class ActivityService {
|
|||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[ACTIVITY] Failed to get stats from cache:', error);
|
signale.warn('[ACTIVITY] Failed to get stats from cache:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default date range to last 30 days if not specified
|
// Default date range to last 30 days if not specified
|
||||||
@@ -241,7 +243,7 @@ export class ActivityService {
|
|||||||
try {
|
try {
|
||||||
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
|
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[ACTIVITY] Failed to cache stats:', error);
|
signale.warn('[ACTIVITY] Failed to cache stats:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
@@ -261,7 +263,7 @@ export class ActivityService {
|
|||||||
await redis.del(...keys);
|
await redis.del(...keys);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
|
signale.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {Keys} from './keys.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Time series data point for analytics
|
* Time series data point for analytics
|
||||||
@@ -57,7 +58,7 @@ export class AnalyticsService {
|
|||||||
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
|
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cacheKey = `analytics:timeseries:${projectId}:${limitedStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
|
const cacheKey = Keys.Analytics.timeseries(projectId, limitedStartDate.toISOString(), effectiveEndDate.toISOString());
|
||||||
const cached = await redis.get(cacheKey);
|
const cached = await redis.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
@@ -190,7 +191,7 @@ export class AnalyticsService {
|
|||||||
const effectiveEndDate = endDate || now;
|
const effectiveEndDate = endDate || now;
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
const cacheKey = `analytics:campaignStats:${projectId}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
|
const cacheKey = Keys.Analytics.campaignStats(projectId, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
|
||||||
const cached = await redis.get(cacheKey);
|
const cached = await redis.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
@@ -294,7 +295,7 @@ export class AnalyticsService {
|
|||||||
const effectiveEndDate = endDate || now;
|
const effectiveEndDate = endDate || now;
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
const cacheKey = `analytics:topEvents:${projectId}:${limit}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
|
const cacheKey = Keys.Analytics.topEvents(projectId, limit, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
|
||||||
const cached = await redis.get(cacheKey);
|
const cached = await redis.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import {EmailSourceType} from '@plunk/db';
|
import {EmailSourceType} from '@plunk/db';
|
||||||
|
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
|
||||||
|
import React from 'react';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
|
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||||
import {stripe} from '../app/stripe.js';
|
import {stripe} from '../app/stripe.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {Keys} from './keys.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -221,7 +225,8 @@ export class BillingLimitService {
|
|||||||
project.billingLimitTransactional !== null;
|
project.billingLimitTransactional !== null;
|
||||||
|
|
||||||
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
||||||
if (!project.subscription && !hasCustomLimits) {
|
// Only enforce free tier limits if billing is enabled
|
||||||
|
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||||
const totalUsage = await this.getTotalUsage(projectId);
|
const totalUsage = await this.getTotalUsage(projectId);
|
||||||
const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
|
const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
|
||||||
const percentage = (totalUsage / freeLimit) * 100;
|
const percentage = (totalUsage / freeLimit) * 100;
|
||||||
@@ -236,6 +241,9 @@ export class BillingLimitService {
|
|||||||
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Send email notification
|
||||||
|
await this.sendLimitExceededEmail(projectId, project.name, totalUsage, freeLimit, 'Free Tier (All Types)');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
warning: false,
|
warning: false,
|
||||||
@@ -257,6 +265,16 @@ export class BillingLimitService {
|
|||||||
percentage,
|
percentage,
|
||||||
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Send email notification (only once per month)
|
||||||
|
await this.sendWarningEmail(
|
||||||
|
projectId,
|
||||||
|
project.name,
|
||||||
|
totalUsage,
|
||||||
|
freeLimit,
|
||||||
|
percentage,
|
||||||
|
'Free Tier (All Types)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -297,6 +315,9 @@ export class BillingLimitService {
|
|||||||
if (project) {
|
if (project) {
|
||||||
// Send notification about limit exceeded
|
// Send notification about limit exceeded
|
||||||
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
|
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
|
||||||
|
|
||||||
|
// Send email notification
|
||||||
|
await this.sendLimitExceededEmail(projectId, project.name, usage, limit, sourceType);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -320,7 +341,17 @@ export class BillingLimitService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (project) {
|
if (project) {
|
||||||
await NtfyService.notifyBillingLimitApproaching(project.name, projectId, usage, limit, percentage, sourceType);
|
await NtfyService.notifyBillingLimitApproaching(
|
||||||
|
project.name,
|
||||||
|
projectId,
|
||||||
|
usage,
|
||||||
|
limit,
|
||||||
|
percentage,
|
||||||
|
sourceType,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send email notification (only once per month)
|
||||||
|
await this.sendWarningEmail(projectId, project.name, usage, limit, percentage, sourceType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +446,8 @@ export class BillingLimitService {
|
|||||||
project.billingLimitTransactional !== null;
|
project.billingLimitTransactional !== null;
|
||||||
|
|
||||||
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
||||||
if (!project.subscription && !hasCustomLimits) {
|
// Only show free tier limits if billing is enabled
|
||||||
|
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
|
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
|
||||||
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
||||||
const percentage = (totalUsage / limit) * 100;
|
const percentage = (totalUsage / limit) * 100;
|
||||||
@@ -455,7 +487,11 @@ export class BillingLimitService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate usage cache for a project
|
* Invalidate usage cache for a project
|
||||||
* Call this when billing period resets or limits are changed
|
* Primarily used in tests to reset cache state between scenarios.
|
||||||
|
* In production, the cache naturally expires after 5 minutes.
|
||||||
|
*
|
||||||
|
* NOTE: Updating billing limits does NOT require clearing usage cache
|
||||||
|
* (use clearNotificationCacheForChangedLimits instead)
|
||||||
*
|
*
|
||||||
* @param projectId - Project ID
|
* @param projectId - Project ID
|
||||||
*/
|
*/
|
||||||
@@ -474,6 +510,69 @@ export class BillingLimitService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear notification cache keys for billing limits that have changed
|
||||||
|
* This allows new warning/limit emails to be sent when updated limits are reached
|
||||||
|
*
|
||||||
|
* @param projectId - Project ID
|
||||||
|
* @param oldLimits - Previous billing limits
|
||||||
|
* @param newLimits - New billing limits
|
||||||
|
*/
|
||||||
|
public static async clearNotificationCacheForChangedLimits(
|
||||||
|
projectId: string,
|
||||||
|
oldLimits: {
|
||||||
|
workflows: number | null;
|
||||||
|
campaigns: number | null;
|
||||||
|
transactional: number | null;
|
||||||
|
},
|
||||||
|
newLimits: {
|
||||||
|
workflows: number | null;
|
||||||
|
campaigns: number | null;
|
||||||
|
transactional: number | null;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
|
||||||
|
// Check workflows limit
|
||||||
|
if (oldLimits.workflows !== newLimits.workflows) {
|
||||||
|
keysToDelete.push(
|
||||||
|
Keys.Billing.warningEmail(projectId, EmailSourceType.WORKFLOW, year, month),
|
||||||
|
Keys.Billing.limitEmail(projectId, EmailSourceType.WORKFLOW, year, month),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check campaigns limit
|
||||||
|
if (oldLimits.campaigns !== newLimits.campaigns) {
|
||||||
|
keysToDelete.push(
|
||||||
|
Keys.Billing.warningEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
|
||||||
|
Keys.Billing.limitEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check transactional limit
|
||||||
|
if (oldLimits.transactional !== newLimits.transactional) {
|
||||||
|
keysToDelete.push(
|
||||||
|
Keys.Billing.warningEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
|
||||||
|
Keys.Billing.limitEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keysToDelete.length > 0) {
|
||||||
|
await Promise.all(keysToDelete.map(key => redis.del(key)));
|
||||||
|
signale.debug(
|
||||||
|
`[BILLING_LIMIT] Cleared notification cache for changed limits in project ${projectId} (${keysToDelete.length} keys)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
signale.warn(`[BILLING_LIMIT] Failed to clear notification cache for ${projectId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Redis cache key for usage count
|
* Get Redis cache key for usage count
|
||||||
*/
|
*/
|
||||||
@@ -481,7 +580,7 @@ export class BillingLimitService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
return `billing:usage:${projectId}:${sourceType}:${year}-${month}`;
|
return Keys.Billing.usage(projectId, sourceType, year, month);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -493,4 +592,110 @@ export class BillingLimitService {
|
|||||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
return {start, end};
|
return {start, end};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send billing limit warning email to project members
|
||||||
|
*/
|
||||||
|
private static async sendWarningEmail(
|
||||||
|
projectId: string,
|
||||||
|
projectName: string,
|
||||||
|
usage: number,
|
||||||
|
limit: number,
|
||||||
|
percentage: number,
|
||||||
|
sourceType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Check if we've already sent this warning email this month
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
|
||||||
|
|
||||||
|
const alreadySent = await redis.get(cacheKey);
|
||||||
|
if (alreadySent === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await prisma.membership.findMany({
|
||||||
|
where: {projectId},
|
||||||
|
include: {user: {select: {email: true}}},
|
||||||
|
});
|
||||||
|
const emails = members.map(m => m.user.email);
|
||||||
|
if (emails.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = React.createElement(BillingLimitWarningEmail, {
|
||||||
|
projectName,
|
||||||
|
projectId,
|
||||||
|
usage,
|
||||||
|
limit,
|
||||||
|
percentage,
|
||||||
|
sourceType,
|
||||||
|
dashboardUrl: DASHBOARD_URI,
|
||||||
|
landingUrl: LANDING_URI,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
|
||||||
|
|
||||||
|
// Mark that we've sent the warning email (expires at end of month)
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
await redis.setex(cacheKey, ttl, '1');
|
||||||
|
} catch (error) {
|
||||||
|
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send billing limit exceeded email to project members
|
||||||
|
*/
|
||||||
|
private static async sendLimitExceededEmail(
|
||||||
|
projectId: string,
|
||||||
|
projectName: string,
|
||||||
|
usage: number,
|
||||||
|
limit: number,
|
||||||
|
sourceType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Check if we've already sent this warning email this month
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
|
||||||
|
|
||||||
|
const alreadySent = await redis.get(cacheKey);
|
||||||
|
if (alreadySent === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await prisma.membership.findMany({
|
||||||
|
where: {projectId},
|
||||||
|
include: {user: {select: {email: true}}},
|
||||||
|
});
|
||||||
|
const emails = members.map(m => m.user.email);
|
||||||
|
if (emails.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = React.createElement(BillingLimitExceededEmail, {
|
||||||
|
projectName,
|
||||||
|
projectId,
|
||||||
|
usage,
|
||||||
|
limit,
|
||||||
|
sourceType,
|
||||||
|
dashboardUrl: DASHBOARD_URI,
|
||||||
|
landingUrl: LANDING_URI,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
|
||||||
|
|
||||||
|
// Mark that we've sent the limit email (expires at end of month)
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
await redis.setex(cacheKey, ttl, '1');
|
||||||
|
} catch (error) {
|
||||||
|
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
||||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
|
||||||
import type {FilterCondition} from '@plunk/types';
|
import type {FilterCondition} from '@plunk/types';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
||||||
|
|
||||||
|
import {BillingLimitService} from './BillingLimitService.js';
|
||||||
import {DomainService} from './DomainService.js';
|
import {DomainService} from './DomainService.js';
|
||||||
import {EmailService} from './EmailService.js';
|
import {EmailService} from './EmailService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {QueueService} from './QueueService.js';
|
import {QueueService} from './QueueService.js';
|
||||||
import {SegmentService} from './SegmentService.js';
|
import {SegmentService} from './SegmentService.js';
|
||||||
import {DASHBOARD_URI} from '../app/constants.js';
|
import {DASHBOARD_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||||
import {sendRawEmail} from './SESService.js';
|
import {sendRawEmail} from './SESService.js';
|
||||||
|
|
||||||
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
|
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
|
||||||
@@ -22,8 +24,8 @@ export interface CreateCampaignData {
|
|||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
from: string;
|
from: string;
|
||||||
fromName?: string;
|
fromName?: string | null;
|
||||||
replyTo?: string;
|
replyTo?: string | null;
|
||||||
audienceType: CampaignAudienceType;
|
audienceType: CampaignAudienceType;
|
||||||
audienceCondition?: FilterCondition;
|
audienceCondition?: FilterCondition;
|
||||||
segmentId?: string;
|
segmentId?: string;
|
||||||
@@ -35,8 +37,8 @@ export interface UpdateCampaignData {
|
|||||||
subject?: string;
|
subject?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
fromName?: string;
|
fromName?: string | null;
|
||||||
replyTo?: string;
|
replyTo?: string | null;
|
||||||
audienceType?: CampaignAudienceType;
|
audienceType?: CampaignAudienceType;
|
||||||
audienceCondition?: FilterCondition;
|
audienceCondition?: FilterCondition;
|
||||||
segmentId?: string;
|
segmentId?: string;
|
||||||
@@ -71,7 +73,7 @@ export class CampaignService {
|
|||||||
SegmentService.validateCondition(data.audienceCondition);
|
SegmentService.validateCondition(data.audienceCondition);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create campaign
|
// Create campaign with initial recipient count of 0
|
||||||
const campaign = await prisma.campaign.create({
|
const campaign = await prisma.campaign.create({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -86,6 +88,7 @@ export class CampaignService {
|
|||||||
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
|
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
|
||||||
segmentId: data.segmentId,
|
segmentId: data.segmentId,
|
||||||
status: CampaignStatus.DRAFT,
|
status: CampaignStatus.DRAFT,
|
||||||
|
totalRecipients: 0, // Will be updated below
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
@@ -94,10 +97,17 @@ export class CampaignService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Calculate and update recipient count for the draft
|
||||||
|
const recipientCount = await this.getRecipientCount(projectId, campaign);
|
||||||
|
const updatedCampaign = await prisma.campaign.update({
|
||||||
|
where: {id: campaign.id},
|
||||||
|
data: {totalRecipients: recipientCount},
|
||||||
|
});
|
||||||
|
|
||||||
// Send notification about campaign creation
|
// Send notification about campaign creation
|
||||||
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
|
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
|
||||||
|
|
||||||
return campaign;
|
return updatedCampaign;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,10 +157,25 @@ export class CampaignService {
|
|||||||
delete (updateData as Record<string, unknown>).segmentId;
|
delete (updateData as Record<string, unknown>).segmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return prisma.campaign.update({
|
// Update the campaign first
|
||||||
|
const updatedCampaign = await prisma.campaign.update({
|
||||||
where: {id: campaignId},
|
where: {id: campaignId},
|
||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If audience-related fields changed and campaign is still a draft, recalculate totalRecipients
|
||||||
|
const audienceChanged =
|
||||||
|
data.audienceType !== undefined || data.segmentId !== undefined || data.audienceCondition !== undefined;
|
||||||
|
|
||||||
|
if (audienceChanged && updatedCampaign.status === CampaignStatus.DRAFT) {
|
||||||
|
const recipientCount = await this.getRecipientCount(projectId, updatedCampaign);
|
||||||
|
return prisma.campaign.update({
|
||||||
|
where: {id: campaignId},
|
||||||
|
data: {totalRecipients: recipientCount},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedCampaign;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -252,7 +277,7 @@ export class CampaignService {
|
|||||||
const campaign = await this.get(projectId, campaignId);
|
const campaign = await this.get(projectId, campaignId);
|
||||||
|
|
||||||
// Create a new campaign with the same data but reset status and stats
|
// Create a new campaign with the same data but reset status and stats
|
||||||
return prisma.campaign.create({
|
const duplicatedCampaign = await prisma.campaign.create({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
name: `${campaign.name} (Copy)`,
|
name: `${campaign.name} (Copy)`,
|
||||||
@@ -266,7 +291,7 @@ export class CampaignService {
|
|||||||
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
||||||
segmentId: campaign.segmentId,
|
segmentId: campaign.segmentId,
|
||||||
status: CampaignStatus.DRAFT,
|
status: CampaignStatus.DRAFT,
|
||||||
totalRecipients: 0,
|
totalRecipients: 0, // Will be updated below
|
||||||
sentCount: 0,
|
sentCount: 0,
|
||||||
deliveredCount: 0,
|
deliveredCount: 0,
|
||||||
openedCount: 0,
|
openedCount: 0,
|
||||||
@@ -274,6 +299,13 @@ export class CampaignService {
|
|||||||
bouncedCount: 0,
|
bouncedCount: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Calculate and update recipient count
|
||||||
|
const recipientCount = await this.getRecipientCount(projectId, duplicatedCampaign);
|
||||||
|
return prisma.campaign.update({
|
||||||
|
where: {id: duplicatedCampaign.id},
|
||||||
|
data: {totalRecipients: recipientCount},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -294,6 +326,24 @@ export class CampaignService {
|
|||||||
throw new HttpException(400, 'Campaign has no recipients');
|
throw new HttpException(400, 'Campaign has no recipients');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check billing limits before scheduling/sending the campaign
|
||||||
|
// This ensures users cannot schedule campaigns that would exceed their quota
|
||||||
|
if (STRIPE_ENABLED) {
|
||||||
|
const limitCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||||
|
|
||||||
|
// If there's a limit set, verify the campaign won't exceed it
|
||||||
|
if (limitCheck.limit !== null) {
|
||||||
|
const projectedUsage = limitCheck.usage + recipientCount;
|
||||||
|
|
||||||
|
if (projectedUsage > limitCheck.limit) {
|
||||||
|
throw new HttpException(
|
||||||
|
403,
|
||||||
|
`Cannot ${scheduledFor ? 'schedule' : 'send'} campaign: would exceed billing limit. Current usage: ${limitCheck.usage}/${limitCheck.limit} emails, campaign recipients: ${recipientCount}. Upgrade your plan or reduce campaign recipients.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (scheduledFor) {
|
if (scheduledFor) {
|
||||||
// Schedule for later
|
// Schedule for later
|
||||||
if (scheduledFor.getTime() <= Date.now()) {
|
if (scheduledFor.getTime() <= Date.now()) {
|
||||||
@@ -412,7 +462,7 @@ export class CampaignService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (campaign.status !== CampaignStatus.SENDING) {
|
if (campaign.status !== CampaignStatus.SENDING) {
|
||||||
console.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
|
signale.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,21 +508,11 @@ export class CampaignService {
|
|||||||
replyTo: campaign.replyTo || undefined,
|
replyTo: campaign.replyTo || undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
||||||
// Continue with other contacts even if one fails
|
// Continue with other contacts even if one fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sent count
|
|
||||||
await prisma.campaign.update({
|
|
||||||
where: {id: campaignId},
|
|
||||||
data: {
|
|
||||||
sentCount: {
|
|
||||||
increment: contacts.length,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Queue next batch if there are more contacts
|
// Queue next batch if there are more contacts
|
||||||
if (hasMore && nextCursor) {
|
if (hasMore && nextCursor) {
|
||||||
await QueueService.queueCampaignBatch({
|
await QueueService.queueCampaignBatch({
|
||||||
@@ -482,27 +522,6 @@ export class CampaignService {
|
|||||||
limit,
|
limit,
|
||||||
cursor: nextCursor,
|
cursor: nextCursor,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// All batches processed, mark campaign as SENT
|
|
||||||
const completedCampaign = await prisma.campaign.update({
|
|
||||||
where: {id: campaignId},
|
|
||||||
data: {
|
|
||||||
status: CampaignStatus.SENT,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
project: {
|
|
||||||
select: {name: true},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send notification about campaign send completed
|
|
||||||
await NtfyService.notifyCampaignSendCompleted(
|
|
||||||
completedCampaign.name,
|
|
||||||
completedCampaign.project.name,
|
|
||||||
completedCampaign.projectId,
|
|
||||||
completedCampaign.totalRecipients || 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ export class EmailService {
|
|||||||
sentAt: new Date().toISOString(),
|
sentAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[EMAIL] Failed to send email ${emailId}:`, error);
|
signale.error(`[EMAIL] Failed to send email ${emailId}:`, error);
|
||||||
|
|
||||||
// Mark as failed
|
// Mark as failed
|
||||||
await prisma.email.update({
|
await prisma.email.update({
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type {Event} from '@plunk/db';
|
import type {Event} from '@plunk/db';
|
||||||
import {Prisma} from '@plunk/db';
|
import {Prisma} from '@plunk/db';
|
||||||
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {ValidationError} from '../exceptions/index.js';
|
||||||
|
import {Keys} from './keys.js';
|
||||||
|
|
||||||
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
||||||
|
|
||||||
@@ -48,11 +51,11 @@ export class EventService {
|
|||||||
* Should be called when workflows are enabled/disabled or updated
|
* Should be called when workflows are enabled/disabled or updated
|
||||||
*/
|
*/
|
||||||
public static async invalidateWorkflowCache(projectId: string): Promise<void> {
|
public static async invalidateWorkflowCache(projectId: string): Promise<void> {
|
||||||
const cacheKey = `workflows:enabled:${projectId}`;
|
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||||
try {
|
try {
|
||||||
await redis.del(cacheKey);
|
await redis.del(cacheKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[EVENT] Failed to invalidate workflow cache:', error);
|
signale.warn('[EVENT] Failed to invalidate workflow cache:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,9 +290,9 @@ export class EventService {
|
|||||||
* @param eventName - The event name to delete
|
* @param eventName - The event name to delete
|
||||||
*/
|
*/
|
||||||
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
|
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
|
||||||
// Prevent deletion of system events
|
// Prevent deletion of reserved system events
|
||||||
if (eventName.startsWith('email.') || eventName.startsWith('segment.')) {
|
if (this.isReservedEvent(eventName)) {
|
||||||
throw new Error('Cannot delete system events (email.* or segment.*)');
|
throw new Error(`Cannot delete reserved system event: ${eventName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if event is in use
|
// Check if event is in use
|
||||||
@@ -311,6 +314,36 @@ export class EventService {
|
|||||||
return {deletedCount: result.count};
|
return {deletedCount: result.count};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an event name is reserved for system use
|
||||||
|
* Reserved patterns:
|
||||||
|
* - email.* (email.sent, email.delivery, email.open, email.click, email.bounce, email.complaint)
|
||||||
|
* - contact.subscribed, contact.unsubscribed
|
||||||
|
* - segment.*.entry, segment.*.exit
|
||||||
|
*
|
||||||
|
* @param eventName - The event name to check
|
||||||
|
* @returns true if the event is reserved, false otherwise
|
||||||
|
*/
|
||||||
|
public static isReservedEvent(eventName: string): boolean {
|
||||||
|
// Email events: email.*
|
||||||
|
if (eventName.startsWith('email.')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contact events: contact.subscribed, contact.unsubscribed
|
||||||
|
if (eventName === 'contact.subscribed' || eventName === 'contact.unsubscribed') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment events: segment.*.entry, segment.*.exit
|
||||||
|
// Pattern: segment.<slug>.entry or segment.<slug>.exit
|
||||||
|
if (eventName.startsWith('segment.') && (eventName.endsWith('.entry') || eventName.endsWith('.exit'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger workflows based on an event
|
* Trigger workflows based on an event
|
||||||
* Uses Redis caching for enabled workflows to improve performance
|
* Uses Redis caching for enabled workflows to improve performance
|
||||||
@@ -322,7 +355,7 @@ export class EventService {
|
|||||||
data?: Record<string, unknown>,
|
data?: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Try to get workflows from cache
|
// Try to get workflows from cache
|
||||||
const cacheKey = `workflows:enabled:${projectId}`;
|
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||||
let workflows;
|
let workflows;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -331,7 +364,7 @@ export class EventService {
|
|||||||
workflows = JSON.parse(cached);
|
workflows = JSON.parse(cached);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[EVENT] Failed to get workflows from cache:', error);
|
signale.warn('[EVENT] Failed to get workflows from cache:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in cache, fetch from database
|
// If not in cache, fetch from database
|
||||||
@@ -353,7 +386,7 @@ export class EventService {
|
|||||||
try {
|
try {
|
||||||
await redis.setex(cacheKey, 300, JSON.stringify(workflows));
|
await redis.setex(cacheKey, 300, JSON.stringify(workflows));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[EVENT] Failed to cache workflows:', error);
|
signale.warn('[EVENT] Failed to cache workflows:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +401,7 @@ export class EventService {
|
|||||||
} else {
|
} else {
|
||||||
// If event is not contact-specific, you might want different logic
|
// If event is not contact-specific, you might want different logic
|
||||||
// For example, trigger for all contacts, or skip
|
// For example, trigger for all contacts, or skip
|
||||||
console.log(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
|
signale.info(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +427,7 @@ export class EventService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!workflow || workflow.steps.length === 0) {
|
if (!workflow || workflow.steps.length === 0) {
|
||||||
console.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
|
signale.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +462,7 @@ export class EventService {
|
|||||||
const triggerStep = workflow.steps[0];
|
const triggerStep = workflow.steps[0];
|
||||||
|
|
||||||
if (!triggerStep) {
|
if (!triggerStep) {
|
||||||
console.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
|
signale.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,14 +477,14 @@ export class EventService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
signale.info(
|
||||||
`[EVENT] Started workflow ${workflowId} execution ${execution.id} for contact ${contactId}${workflow.allowReentry ? ' (re-entry allowed)' : ''}`,
|
`[EVENT] Started workflow ${workflowId} execution ${execution.id} for contact ${contactId}${workflow.allowReentry ? ' (re-entry allowed)' : ''}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start executing the workflow
|
// Start executing the workflow
|
||||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
|
signale.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
PutBucketPolicyCommand,
|
PutBucketPolicyCommand,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
S3_ENDPOINT,
|
S3_ENDPOINT,
|
||||||
S3_ACCESS_KEY_ID,
|
S3_ACCESS_KEY_ID,
|
||||||
@@ -69,13 +71,13 @@ export async function initializeBucket(): Promise<void> {
|
|||||||
Bucket: S3_BUCKET,
|
Bucket: S3_BUCKET,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
console.log(`[S3] Created bucket: ${S3_BUCKET}`);
|
signale.info(`[S3] Created bucket: ${S3_BUCKET}`);
|
||||||
} catch (createError) {
|
} catch (createError) {
|
||||||
console.error('[S3] Failed to create bucket:', createError);
|
signale.error('[S3] Failed to create bucket:', createError);
|
||||||
throw createError;
|
throw createError;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('[S3] Failed to check bucket:', error);
|
signale.error('[S3] Failed to check bucket:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,10 +105,10 @@ export async function initializeBucket(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!bucketExists) {
|
if (!bucketExists) {
|
||||||
console.log(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
|
signale.info(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
|
||||||
}
|
}
|
||||||
} catch (policyError) {
|
} catch (policyError) {
|
||||||
console.error('[S3] Failed to set bucket policy:', policyError);
|
signale.error('[S3] Failed to set bucket policy:', policyError);
|
||||||
// Don't throw - bucket was created but policy failed
|
// Don't throw - bucket was created but policy failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {SES} from '@aws-sdk/client-ses';
|
import {SES} from '@aws-sdk/client-ses';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AWS_SES_ACCESS_KEY_ID,
|
AWS_SES_ACCESS_KEY_ID,
|
||||||
@@ -6,7 +7,8 @@ import {
|
|||||||
AWS_SES_SECRET_ACCESS_KEY,
|
AWS_SES_SECRET_ACCESS_KEY,
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
SES_CONFIGURATION_SET,
|
SES_CONFIGURATION_SET,
|
||||||
SES_CONFIGURATION_SET_NO_TRACKING
|
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||||
|
TRACKING_TOGGLE_ENABLED
|
||||||
} from '../app/constants.js';
|
} from '../app/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,10 +162,15 @@ ${breakLongLines(attachment.content, 76, true)}`,
|
|||||||
: ''
|
: ''
|
||||||
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
|
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
|
||||||
|
|
||||||
|
// Determine which configuration set to use
|
||||||
|
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
|
||||||
|
const configurationSetName =
|
||||||
|
TRACKING_TOGGLE_ENABLED && !tracking ? SES_CONFIGURATION_SET_NO_TRACKING : SES_CONFIGURATION_SET;
|
||||||
|
|
||||||
// Send via SES
|
// Send via SES
|
||||||
const response = await ses.sendRawEmail({
|
const response = await ses.sendRawEmail({
|
||||||
Destinations: destinations,
|
Destinations: destinations,
|
||||||
ConfigurationSetName: tracking ? SES_CONFIGURATION_SET : SES_CONFIGURATION_SET_NO_TRACKING,
|
ConfigurationSetName: configurationSetName,
|
||||||
RawMessage: {
|
RawMessage: {
|
||||||
Data: new TextEncoder().encode(rawMessage),
|
Data: new TextEncoder().encode(rawMessage),
|
||||||
},
|
},
|
||||||
@@ -250,3 +257,26 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
|
|||||||
ForwardingEnabled: false,
|
ForwardingEnabled: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AWS SES account sending quota and rate limit
|
||||||
|
* @returns MaxSendRate (emails per second) or null if the call fails
|
||||||
|
*/
|
||||||
|
export const getSendingQuota = async (): Promise<{
|
||||||
|
maxSendRate: number;
|
||||||
|
max24HourSend: number;
|
||||||
|
sentLast24Hours: number;
|
||||||
|
} | null> => {
|
||||||
|
try {
|
||||||
|
const quota = await ses.getSendQuota({});
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxSendRate: quota.MaxSendRate ?? 14, // Default to sandbox limit if not provided
|
||||||
|
max24HourSend: quota.Max24HourSend ?? 200, // Default sandbox daily limit
|
||||||
|
sentLast24Hours: quota.SentLast24Hours ?? 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
signale.error('[SES] Failed to fetch sending quota:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
|
||||||
|
import React from 'react';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {Keys} from './keys.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {QueueService} from './QueueService.js';
|
import {QueueService} from './QueueService.js';
|
||||||
|
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security thresholds for bounce and complaint rates
|
* Security thresholds for bounce and complaint rates
|
||||||
@@ -45,7 +49,6 @@ interface SecurityStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SecurityService {
|
export class SecurityService {
|
||||||
private static readonly CACHE_PREFIX = 'security';
|
|
||||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +57,7 @@ export class SecurityService {
|
|||||||
public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> {
|
public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> {
|
||||||
try {
|
try {
|
||||||
// Try to get from cache first
|
// Try to get from cache first
|
||||||
const cacheKey = this.getCacheKey(projectId, 'rates');
|
const cacheKey = Keys.Security.rates(projectId);
|
||||||
const cached = await redis.get(cacheKey);
|
const cached = await redis.get(cacheKey);
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -107,9 +110,31 @@ export class SecurityService {
|
|||||||
// Get current security status
|
// Get current security status
|
||||||
const status = await this.getSecurityStatus(projectId);
|
const status = await this.getSecurityStatus(projectId);
|
||||||
|
|
||||||
// If project should be disabled, disable it
|
// If project should be disabled, disable it (only if auto-disable is enabled)
|
||||||
if (status.shouldDisable) {
|
if (status.shouldDisable && AUTO_PROJECT_DISABLE) {
|
||||||
await this.disableProject(projectId, status);
|
await this.disableProject(projectId, status);
|
||||||
|
} else if (status.shouldDisable && !AUTO_PROJECT_DISABLE) {
|
||||||
|
// Log critical violations but don't auto-disable (self-hosted mode)
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: {id: projectId},
|
||||||
|
select: {name: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (project) {
|
||||||
|
signale.error(
|
||||||
|
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
|
||||||
|
status.violations,
|
||||||
|
);
|
||||||
|
signale.info(
|
||||||
|
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
|
||||||
|
);
|
||||||
|
signale.info(
|
||||||
|
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send notification about critical security violations
|
||||||
|
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
|
||||||
|
}
|
||||||
} else if (status.warnings.length > 0) {
|
} else if (status.warnings.length > 0) {
|
||||||
// Log warnings for monitoring
|
// Log warnings for monitoring
|
||||||
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
|
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
|
||||||
@@ -137,13 +162,55 @@ export class SecurityService {
|
|||||||
*/
|
*/
|
||||||
public static async invalidateCache(projectId: string): Promise<void> {
|
public static async invalidateCache(projectId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cacheKey = this.getCacheKey(projectId, 'rates');
|
const cacheKey = Keys.Security.rates(projectId);
|
||||||
await redis.del(cacheKey);
|
await redis.del(cacheKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, error);
|
signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user is a member of any disabled project
|
||||||
|
* Users with disabled projects cannot create new projects
|
||||||
|
*/
|
||||||
|
public static async userHasDisabledProject(userId: string): Promise<{
|
||||||
|
hasDisabledProject: boolean;
|
||||||
|
disabledProjectNames: string[];
|
||||||
|
}> {
|
||||||
|
const disabledMemberships = await prisma.membership.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
project: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasDisabledProject: disabledMemberships.length > 0,
|
||||||
|
disabledProjectNames: disabledMemberships.map(m => m.project.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific project is disabled
|
||||||
|
*/
|
||||||
|
public static async isProjectDisabled(projectId: string): Promise<boolean> {
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: {id: projectId},
|
||||||
|
select: {disabled: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
return project?.disabled ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a project's security metrics (for admin/dashboard display)
|
* Get a project's security metrics (for admin/dashboard display)
|
||||||
*/
|
*/
|
||||||
@@ -167,13 +234,6 @@ export class SecurityService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cache key for security metrics
|
|
||||||
*/
|
|
||||||
private static getCacheKey(projectId: string, type: 'rates'): string {
|
|
||||||
return `${this.CACHE_PREFIX}:${projectId}:${type}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate bounce and complaint rates for a project
|
* Calculate bounce and complaint rates for a project
|
||||||
*/
|
*/
|
||||||
@@ -345,6 +405,29 @@ export class SecurityService {
|
|||||||
|
|
||||||
// Send urgent notification about project suspension
|
// Send urgent notification about project suspension
|
||||||
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
|
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
|
||||||
|
|
||||||
|
// Send email notification to project members
|
||||||
|
try {
|
||||||
|
const members = await prisma.membership.findMany({
|
||||||
|
where: {projectId},
|
||||||
|
include: {user: {select: {email: true}}},
|
||||||
|
});
|
||||||
|
const emails = members.map(m => m.user.email);
|
||||||
|
if (emails.length > 0) {
|
||||||
|
const template = React.createElement(ProjectDisabledEmail, {
|
||||||
|
projectName: project.name,
|
||||||
|
projectId,
|
||||||
|
violations: status.violations,
|
||||||
|
dashboardUrl: DASHBOARD_URI,
|
||||||
|
landingUrl: LANDING_URI,
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Security Risk', template)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (emailError) {
|
||||||
|
signale.error(`[SECURITY] Failed to send project disabled email:`, emailError);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
|
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
||||||
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
@@ -276,7 +277,7 @@ export class SegmentService {
|
|||||||
data: {memberCount},
|
data: {memberCount},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to update count for segment ${segment.id}:`, error);
|
signale.error(`Failed to update count for segment ${segment.id}:`, error);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -383,7 +384,7 @@ export class SegmentService {
|
|||||||
segmentName: segment.name,
|
segmentName: segment.name,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SEGMENT] Failed to track segment entry event for contact ${contactId}:`, error);
|
signale.error(`[SEGMENT] Failed to track segment entry event for contact ${contactId}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,7 +415,7 @@ export class SegmentService {
|
|||||||
segmentName: segment.name,
|
segmentName: segment.name,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SEGMENT] Failed to track segment exit event for contact ${contactId}:`, error);
|
signale.error(`[SEGMENT] Failed to track segment exit event for contact ${contactId}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,7 +426,7 @@ export class SegmentService {
|
|||||||
data: {memberCount: matchingContactIds.size},
|
data: {memberCount: matchingContactIds.size},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
signale.info(
|
||||||
`[SEGMENT] Computed membership for segment ${segmentId}: added ${toAdd.length}, removed ${toRemove.length}, total ${matchingContactIds.size}`,
|
`[SEGMENT] Computed membership for segment ${segmentId}: added ${toAdd.length}, removed ${toRemove.length}, total ${matchingContactIds.size}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ export class TemplateService {
|
|||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
from: string;
|
from: string;
|
||||||
fromName?: string;
|
fromName?: string | null;
|
||||||
replyTo?: string;
|
replyTo?: string | null;
|
||||||
type?: Template['type'];
|
type?: Template['type'];
|
||||||
},
|
},
|
||||||
): Promise<Template> {
|
): Promise<Template> {
|
||||||
@@ -120,8 +120,8 @@ export class TemplateService {
|
|||||||
subject?: string;
|
subject?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
fromName?: string;
|
fromName?: string | null;
|
||||||
replyTo?: string;
|
replyTo?: string | null;
|
||||||
type?: Template['type'];
|
type?: Template['type'];
|
||||||
},
|
},
|
||||||
): Promise<Template> {
|
): Promise<Template> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
|
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
|
||||||
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
|
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
|
||||||
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
@@ -786,7 +787,7 @@ export class WorkflowService {
|
|||||||
// Start executing the workflow asynchronously
|
// Start executing the workflow asynchronously
|
||||||
// Don't await - let it run in background
|
// Don't await - let it run in background
|
||||||
WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => {
|
WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => {
|
||||||
console.error('Error executing workflow:', error);
|
signale.error('Error executing workflow:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
return execution;
|
return execution;
|
||||||
|
|||||||
@@ -5,6 +5,28 @@ import {EmailService} from '../EmailService';
|
|||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
import {redis} from '../../database/redis';
|
import {redis} from '../../database/redis';
|
||||||
|
|
||||||
|
// Mock STRIPE_ENABLED and STRIPE_SK for free tier tests
|
||||||
|
vi.mock('../../app/constants.js', async () => {
|
||||||
|
const actual = await vi.importActual('../../app/constants.js');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
STRIPE_ENABLED: true,
|
||||||
|
STRIPE_SK: 'sk_test_mock_key_for_testing',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the stripe client to avoid actual Stripe API calls
|
||||||
|
vi.mock('../../app/stripe.js', () => ({
|
||||||
|
stripe: {
|
||||||
|
customers: {
|
||||||
|
retrieve: vi.fn().mockResolvedValue({
|
||||||
|
deleted: false,
|
||||||
|
currency: 'usd',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe('BillingLimitService - Critical Enforcement', () => {
|
describe('BillingLimitService - Critical Enforcement', () => {
|
||||||
let projectId: string;
|
let projectId: string;
|
||||||
let contactId: string;
|
let contactId: string;
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import {describe, it, expect, beforeEach} from 'vitest';
|
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
||||||
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
|
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
|
||||||
import {CampaignService} from '../CampaignService';
|
import {CampaignService} from '../CampaignService';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
|
// Mock STRIPE_ENABLED for billing limit tests
|
||||||
|
vi.mock('../../app/constants.js', async () => {
|
||||||
|
const actual = await vi.importActual('../../app/constants.js');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
STRIPE_ENABLED: true,
|
||||||
|
STRIPE_SK: 'sk_test_mock_key_for_testing',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('CampaignService', () => {
|
describe('CampaignService', () => {
|
||||||
let projectId: string;
|
let projectId: string;
|
||||||
const prisma = getPrismaClient();
|
const prisma = getPrismaClient();
|
||||||
@@ -356,4 +366,227 @@ describe('CampaignService', () => {
|
|||||||
expect(matching).toBe(5);
|
expect(matching).toBe(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('send', () => {
|
||||||
|
it('should throw error when campaign has no recipients', async () => {
|
||||||
|
// Create campaign with no contacts in project
|
||||||
|
const campaign = await factories.createCampaign({
|
||||||
|
projectId,
|
||||||
|
status: CampaignStatus.DRAFT,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow('Campaign has no recipients');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send campaign successfully when recipients are under billing limit', async () => {
|
||||||
|
// Set billing limit for campaigns
|
||||||
|
await prisma.project.update({
|
||||||
|
where: {id: projectId},
|
||||||
|
data: {billingLimitCampaigns: 100},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 5 subscribed contacts
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await factories.createContact({
|
||||||
|
projectId,
|
||||||
|
subscribed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create campaign targeting all contacts
|
||||||
|
const campaign = await factories.createCampaign({
|
||||||
|
projectId,
|
||||||
|
status: CampaignStatus.DRAFT,
|
||||||
|
audienceType: CampaignAudienceType.ALL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should send successfully (5 recipients < 100 limit)
|
||||||
|
const sentCampaign = await CampaignService.send(projectId, campaign.id);
|
||||||
|
|
||||||
|
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
|
||||||
|
expect(sentCampaign.totalRecipients).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw 403 error when campaign would exceed billing limit', async () => {
|
||||||
|
// Set billing limit for campaigns to 10
|
||||||
|
await prisma.project.update({
|
||||||
|
where: {id: projectId},
|
||||||
|
data: {billingLimitCampaigns: 10},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 5 existing campaign emails (usage = 5)
|
||||||
|
const contact = await factories.createContact({projectId, subscribed: true});
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: 'CAMPAIGN',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create campaign with 10 subscribed contacts (would result in 15 total)
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await factories.createContact({
|
||||||
|
projectId,
|
||||||
|
subscribed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaign = await factories.createCampaign({
|
||||||
|
projectId,
|
||||||
|
status: CampaignStatus.DRAFT,
|
||||||
|
audienceType: CampaignAudienceType.ALL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should throw error because 5 + 11 = 16 > 10 limit (11 contacts: 1 from email creation + 10 new)
|
||||||
|
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(/exceed billing limit/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw 403 error when scheduled campaign would exceed billing limit', async () => {
|
||||||
|
// Set billing limit for campaigns to 20
|
||||||
|
await prisma.project.update({
|
||||||
|
where: {id: projectId},
|
||||||
|
data: {billingLimitCampaigns: 20},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 15 existing campaign emails
|
||||||
|
const contact = await factories.createContact({projectId, subscribed: true});
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: 'CAMPAIGN',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create campaign with 10 subscribed contacts (would result in 25 total)
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await factories.createContact({
|
||||||
|
projectId,
|
||||||
|
subscribed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaign = await factories.createCampaign({
|
||||||
|
projectId,
|
||||||
|
status: CampaignStatus.DRAFT,
|
||||||
|
audienceType: CampaignAudienceType.ALL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
|
||||||
|
|
||||||
|
// Should throw error when scheduling because 15 + 10 = 25 > 20 limit
|
||||||
|
await expect(CampaignService.send(projectId, campaign.id, scheduledFor)).rejects.toThrow(
|
||||||
|
/Cannot schedule campaign.*exceed billing limit/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send campaign successfully when billing limit is null (unlimited)', async () => {
|
||||||
|
// Set billing limit to null (unlimited)
|
||||||
|
await prisma.project.update({
|
||||||
|
where: {id: projectId},
|
||||||
|
data: {billingLimitCampaigns: null},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 1000 subscribed contacts
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
await factories.createContact({
|
||||||
|
projectId,
|
||||||
|
subscribed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaign = await factories.createCampaign({
|
||||||
|
projectId,
|
||||||
|
status: CampaignStatus.DRAFT,
|
||||||
|
audienceType: CampaignAudienceType.ALL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should send successfully (no limit)
|
||||||
|
const sentCampaign = await CampaignService.send(projectId, campaign.id);
|
||||||
|
|
||||||
|
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
|
||||||
|
expect(sentCampaign.totalRecipients).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow campaign that exactly reaches billing limit', async () => {
|
||||||
|
// Set billing limit for campaigns to 10
|
||||||
|
await prisma.project.update({
|
||||||
|
where: {id: projectId},
|
||||||
|
data: {billingLimitCampaigns: 10},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 5 existing campaign emails
|
||||||
|
const contact = await factories.createContact({projectId, subscribed: true});
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await factories.createEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
sourceType: 'CAMPAIGN',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 4 more subscribed contacts (total of 5 with the one above: 1 + 4 = 5)
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await factories.createContact({
|
||||||
|
projectId,
|
||||||
|
subscribed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaign = await factories.createCampaign({
|
||||||
|
projectId,
|
||||||
|
status: CampaignStatus.DRAFT,
|
||||||
|
audienceType: CampaignAudienceType.ALL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should send successfully because 5 + 5 = 10 (exactly at limit)
|
||||||
|
const sentCampaign = await CampaignService.send(projectId, campaign.id);
|
||||||
|
|
||||||
|
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
|
||||||
|
expect(sentCampaign.totalRecipients).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when campaign has already been sent', async () => {
|
||||||
|
const campaign = await factories.createCampaign({
|
||||||
|
projectId,
|
||||||
|
status: CampaignStatus.SENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(
|
||||||
|
'Campaign has already been sent or is currently sending',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should schedule campaign successfully when recipients are under billing limit', async () => {
|
||||||
|
// Set billing limit for campaigns
|
||||||
|
await prisma.project.update({
|
||||||
|
where: {id: projectId},
|
||||||
|
data: {billingLimitCampaigns: 50},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 10 subscribed contacts
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await factories.createContact({
|
||||||
|
projectId,
|
||||||
|
subscribed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaign = await factories.createCampaign({
|
||||||
|
projectId,
|
||||||
|
status: CampaignStatus.DRAFT,
|
||||||
|
audienceType: CampaignAudienceType.ALL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
|
||||||
|
|
||||||
|
// Should schedule successfully (10 recipients < 50 limit)
|
||||||
|
const scheduledCampaign = await CampaignService.send(projectId, campaign.id, scheduledFor);
|
||||||
|
|
||||||
|
expect(scheduledCampaign.status).toBe(CampaignStatus.SCHEDULED);
|
||||||
|
expect(scheduledCampaign.scheduledFor).toEqual(scheduledFor);
|
||||||
|
expect(scheduledCampaign.totalRecipients).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||||
import {ActionSchemas} from '@plunk/shared';
|
import {ActionSchemas} from '@plunk/shared';
|
||||||
import {EmailService} from '../EmailService';
|
import {EmailService} from '../EmailService';
|
||||||
@@ -669,7 +669,6 @@ describe('EmailService', () => {
|
|||||||
// ========================================
|
// ========================================
|
||||||
describe('Attachment Schema Validation', () => {
|
describe('Attachment Schema Validation', () => {
|
||||||
it('should validate attachment count limit (max 10)', () => {
|
it('should validate attachment count limit (max 10)', () => {
|
||||||
|
|
||||||
const tooManyAttachments = Array.from({length: 11}, (_, i) => ({
|
const tooManyAttachments = Array.from({length: 11}, (_, i) => ({
|
||||||
filename: `file${i}.txt`,
|
filename: `file${i}.txt`,
|
||||||
content: Buffer.from('content').toString('base64'),
|
content: Buffer.from('content').toString('base64'),
|
||||||
@@ -690,7 +689,6 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should validate attachment size limit (10MB total)', () => {
|
it('should validate attachment size limit (10MB total)', () => {
|
||||||
|
|
||||||
// Exceeds ~13.3M base64 chars limit
|
// Exceeds ~13.3M base64 chars limit
|
||||||
const largeContent = 'A'.repeat(14000000);
|
const largeContent = 'A'.repeat(14000000);
|
||||||
|
|
||||||
@@ -711,11 +709,11 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should accept attachments within size limit', () => {
|
it('should accept attachments within size limit', () => {
|
||||||
|
|
||||||
const validContent = Buffer.from('Small file content').toString('base64');
|
const validContent = Buffer.from('Small file content').toString('base64');
|
||||||
|
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: '[email protected]',
|
to: '[email protected]',
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
attachments: [
|
attachments: [
|
||||||
@@ -731,7 +729,6 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reject attachment with missing required fields', () => {
|
it('should reject attachment with missing required fields', () => {
|
||||||
|
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: '[email protected]',
|
to: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
@@ -748,7 +745,6 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reject attachment with empty filename', () => {
|
it('should reject attachment with empty filename', () => {
|
||||||
|
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: '[email protected]',
|
to: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
@@ -766,7 +762,6 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reject attachment with filename exceeding 255 chars', () => {
|
it('should reject attachment with filename exceeding 255 chars', () => {
|
||||||
|
|
||||||
const tooLongFilename = 'a'.repeat(256) + '.pdf';
|
const tooLongFilename = 'a'.repeat(256) + '.pdf';
|
||||||
|
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
@@ -786,18 +781,12 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should accept valid attachment with various content types', () => {
|
it('should accept valid attachment with various content types', () => {
|
||||||
|
const contentTypes = ['application/pdf', 'image/png', 'image/jpeg', 'text/plain', 'application/zip'];
|
||||||
const contentTypes = [
|
|
||||||
'application/pdf',
|
|
||||||
'image/png',
|
|
||||||
'image/jpeg',
|
|
||||||
'text/plain',
|
|
||||||
'application/zip',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const contentType of contentTypes) {
|
for (const contentType of contentTypes) {
|
||||||
const result = ActionSchemas.send.safeParse({
|
const result = ActionSchemas.send.safeParse({
|
||||||
to: '[email protected]',
|
to: '[email protected]',
|
||||||
|
from: '[email protected]',
|
||||||
subject: 'Test',
|
subject: 'Test',
|
||||||
body: 'Test',
|
body: 'Test',
|
||||||
attachments: [
|
attachments: [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {WorkflowExecutionStatus, WorkflowTriggerType} from '@plunk/db';
|
import {WorkflowExecutionStatus, WorkflowTriggerType} from '@plunk/db';
|
||||||
import {EventService} from '../EventService';
|
import {EventService} from '../EventService';
|
||||||
|
import {Keys} from '../keys';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
// Mock Redis for caching tests - must be inline to avoid hoisting issues
|
// Mock Redis for caching tests - must be inline to avoid hoisting issues
|
||||||
@@ -503,7 +504,7 @@ describe('EventService', () => {
|
|||||||
const {redis} = await import('../../database/redis');
|
const {redis} = await import('../../database/redis');
|
||||||
|
|
||||||
// Set cache
|
// Set cache
|
||||||
const cacheKey = `workflows:enabled:${projectId}`;
|
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||||
await redis.set(cacheKey, JSON.stringify([{id: 'test'}]));
|
await redis.set(cacheKey, JSON.stringify([{id: 'test'}]));
|
||||||
|
|
||||||
// Verify cache exists
|
// Verify cache exists
|
||||||
@@ -852,4 +853,90 @@ describe('EventService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// RESERVED EVENT VALIDATION
|
||||||
|
// ========================================
|
||||||
|
describe('isReservedEvent', () => {
|
||||||
|
describe('Email events (email.*)', () => {
|
||||||
|
it('should identify email.sent as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('email.sent')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify email.delivery as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('email.delivery')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify email.open as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('email.open')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify email.click as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('email.click')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify email.bounce as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('email.bounce')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify email.complaint as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('email.complaint')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify any email.* pattern as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('email.custom')).toBe(true);
|
||||||
|
expect(EventService.isReservedEvent('email.anything')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Contact events', () => {
|
||||||
|
it('should identify contact.subscribed as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('contact.subscribed')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify contact.unsubscribed as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('contact.unsubscribed')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not identify other contact.* events as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('contact.created')).toBe(false);
|
||||||
|
expect(EventService.isReservedEvent('contact.updated')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Segment events (segment.*.entry, segment.*.exit)', () => {
|
||||||
|
it('should identify segment.*.entry as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('segment.vip-users.entry')).toBe(true);
|
||||||
|
expect(EventService.isReservedEvent('segment.premium.entry')).toBe(true);
|
||||||
|
expect(EventService.isReservedEvent('segment.active-subscribers.entry')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify segment.*.exit as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('segment.vip-users.exit')).toBe(true);
|
||||||
|
expect(EventService.isReservedEvent('segment.premium.exit')).toBe(true);
|
||||||
|
expect(EventService.isReservedEvent('segment.active-subscribers.exit')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not identify other segment.* events as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('segment.created')).toBe(false);
|
||||||
|
expect(EventService.isReservedEvent('segment.vip-users.updated')).toBe(false);
|
||||||
|
expect(EventService.isReservedEvent('segment.premium')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Non-reserved events', () => {
|
||||||
|
it('should not identify custom user events as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('user.signup')).toBe(false);
|
||||||
|
expect(EventService.isReservedEvent('purchase.completed')).toBe(false);
|
||||||
|
expect(EventService.isReservedEvent('order.placed')).toBe(false);
|
||||||
|
expect(EventService.isReservedEvent('custom.event')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not identify events with similar prefixes as reserved', () => {
|
||||||
|
expect(EventService.isReservedEvent('emails.sent')).toBe(false);
|
||||||
|
expect(EventService.isReservedEvent('contacts.subscribed')).toBe(false);
|
||||||
|
expect(EventService.isReservedEvent('segments.entry')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
|
import {WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
|
||||||
import {WorkflowService} from '../WorkflowService';
|
import {WorkflowService} from '../WorkflowService';
|
||||||
|
import {Keys} from '../keys';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
// Mock Redis for caching tests - must be inline to avoid hoisting issues
|
// Mock Redis for caching tests - must be inline to avoid hoisting issues
|
||||||
@@ -130,7 +131,7 @@ describe('WorkflowService', () => {
|
|||||||
|
|
||||||
it('should invalidate cache when creating enabled workflow', async () => {
|
it('should invalidate cache when creating enabled workflow', async () => {
|
||||||
const {redis} = await import('../../database/redis');
|
const {redis} = await import('../../database/redis');
|
||||||
const cacheKey = `workflows:enabled:${projectId}`;
|
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||||
|
|
||||||
// Set cache
|
// Set cache
|
||||||
await redis.set(cacheKey, JSON.stringify([{id: 'old'}]));
|
await redis.set(cacheKey, JSON.stringify([{id: 'old'}]));
|
||||||
@@ -286,7 +287,7 @@ describe('WorkflowService', () => {
|
|||||||
|
|
||||||
it('should invalidate cache when enabling workflow', async () => {
|
it('should invalidate cache when enabling workflow', async () => {
|
||||||
const {redis} = await import('../../database/redis');
|
const {redis} = await import('../../database/redis');
|
||||||
const cacheKey = `workflows:enabled:${projectId}`;
|
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||||
|
|
||||||
const workflow = await factories.createWorkflow({
|
const workflow = await factories.createWorkflow({
|
||||||
projectId,
|
projectId,
|
||||||
@@ -326,7 +327,7 @@ describe('WorkflowService', () => {
|
|||||||
|
|
||||||
it('should invalidate cache when deleting enabled workflow', async () => {
|
it('should invalidate cache when deleting enabled workflow', async () => {
|
||||||
const {redis} = await import('../../database/redis');
|
const {redis} = await import('../../database/redis');
|
||||||
const cacheKey = `workflows:enabled:${projectId}`;
|
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||||
|
|
||||||
const workflow = await factories.createWorkflow({
|
const workflow = await factories.createWorkflow({
|
||||||
projectId,
|
projectId,
|
||||||
|
|||||||
@@ -15,4 +15,41 @@ export const Keys = {
|
|||||||
return `domain:project:${projectId}`;
|
return `domain:project:${projectId}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Billing: {
|
||||||
|
usage(projectId: string, sourceType: string, year: number, month: string): string {
|
||||||
|
return `billing:usage:${projectId}:${sourceType}:${year}-${month}`;
|
||||||
|
},
|
||||||
|
warningEmail(projectId: string, sourceType: string, year: number, month: string): string {
|
||||||
|
return `billing:warning_email:${projectId}:${sourceType}:${year}-${month}`;
|
||||||
|
},
|
||||||
|
limitEmail(projectId: string, sourceType: string, year: number, month: string): string {
|
||||||
|
return `billing:limit_email:${projectId}:${sourceType}:${year}-${month}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Security: {
|
||||||
|
rates(projectId: string): string {
|
||||||
|
return `security:${projectId}:rates`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Activity: {
|
||||||
|
stats(projectId: string, startTime: number | string, endTime: number | string): string {
|
||||||
|
return `activity:stats:${projectId}:${startTime}:${endTime}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Analytics: {
|
||||||
|
timeseries(projectId: string, startDate: string, endDate: string): string {
|
||||||
|
return `analytics:timeseries:${projectId}:${startDate}:${endDate}`;
|
||||||
|
},
|
||||||
|
campaignStats(projectId: string, startDate: string, endDate: string): string {
|
||||||
|
return `analytics:campaignStats:${projectId}:${startDate}:${endDate}`;
|
||||||
|
},
|
||||||
|
topEvents(projectId: string, limit: number, startDate: string, endDate: string): string {
|
||||||
|
return `analytics:topEvents:${projectId}:${limit}:${startDate}:${endDate}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Workflow: {
|
||||||
|
enabled(projectId: string): string {
|
||||||
|
return `workflows:enabled:${projectId}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export function buildEmailFieldsUpdate(data: {
|
|||||||
subject?: string;
|
subject?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
fromName?: string;
|
fromName?: string | null;
|
||||||
replyTo?: string;
|
replyTo?: string | null;
|
||||||
}): Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput {
|
}): Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput {
|
||||||
return buildUpdateData(data) as Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput;
|
return buildUpdateData(data) as Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
"@plunk/shared": "*",
|
"@plunk/shared": "*",
|
||||||
"@plunk/ui": "*",
|
"@plunk/ui": "*",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.7",
|
"next": "^16.0.10",
|
||||||
"next-seo": "^6.6.0",
|
"next-seo": "^6.6.0",
|
||||||
"react": "19.2.1",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.3",
|
||||||
"sonner": "^2.0.6"
|
"sonner": "^2.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"clean": "rimraf node_modules .next .turbo"
|
"clean": "rimraf node_modules .next .turbo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.39.0",
|
||||||
"@plunk/db": "*",
|
"@plunk/db": "*",
|
||||||
"@plunk/shared": "*",
|
"@plunk/shared": "*",
|
||||||
"@plunk/ui": "*",
|
"@plunk/ui": "*",
|
||||||
@@ -19,6 +22,7 @@
|
|||||||
"@tiptap/extension-image": "^3.11.0",
|
"@tiptap/extension-image": "^3.11.0",
|
||||||
"@tiptap/extension-link": "^3.11.0",
|
"@tiptap/extension-link": "^3.11.0",
|
||||||
"@tiptap/extension-mention": "^3.11.0",
|
"@tiptap/extension-mention": "^3.11.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.11.0",
|
||||||
"@tiptap/extension-table": "^3.11.0",
|
"@tiptap/extension-table": "^3.11.0",
|
||||||
"@tiptap/extension-table-cell": "^3.11.0",
|
"@tiptap/extension-table-cell": "^3.11.0",
|
||||||
"@tiptap/extension-table-header": "^3.11.0",
|
"@tiptap/extension-table-header": "^3.11.0",
|
||||||
@@ -30,16 +34,18 @@
|
|||||||
"@tiptap/starter-kit": "^3.11.0",
|
"@tiptap/starter-kit": "^3.11.0",
|
||||||
"@tiptap/suggestion": "^3.11.0",
|
"@tiptap/suggestion": "^3.11.0",
|
||||||
"@types/dagre": "^0.7.53",
|
"@types/dagre": "^0.7.53",
|
||||||
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"@xyflow/react": "^12.9.3",
|
"@xyflow/react": "^12.9.3",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"juice": "^11.0.3",
|
"juice": "^11.0.3",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.7",
|
"next": "^16.0.10",
|
||||||
"next-seo": "^6.6.0",
|
"next-seo": "^6.6.0",
|
||||||
"nuqs": "^2.7.3",
|
"nuqs": "^2.7.3",
|
||||||
"react": "19.2.1",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
"react-intl": "^7.1.14",
|
"react-intl": "^7.1.14",
|
||||||
"react-is": "^19.0.0",
|
"react-is": "^19.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {memo, useCallback, useEffect, useMemo, useState} from 'react';
|
import {memo, useEffect, useMemo, useState} from 'react';
|
||||||
import {useForm} from 'react-hook-form';
|
import {useForm} from 'react-hook-form';
|
||||||
import {zodResolver} from '@hookform/resolvers/zod';
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
import {BillingLimitSchemas} from '@plunk/shared';
|
import {BillingLimitSchemas} from '@plunk/shared';
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import {AlertCircle, AlertTriangle, Check} from 'lucide-react';
|
import {AlertCircle, AlertTriangle, Check} from 'lucide-react';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import type {z} from 'zod';
|
import type {z} from 'zod';
|
||||||
import {useBillingLimits, type BillingLimitsData, type CategoryLimit} from '../lib/hooks/useBillingLimits';
|
import {type BillingLimitsData, type CategoryLimit, useBillingLimits} from '../lib/hooks/useBillingLimits';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
|
|
||||||
// Price per email in the smallest currency unit (e.g., cents for USD/EUR)
|
// Price per email in the smallest currency unit (e.g., cents for USD/EUR)
|
||||||
@@ -50,7 +50,7 @@ const formatEmailCost = (emailCount: number, currency: string | null): string =>
|
|||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
}).format(costInCents / 100);
|
}).format(costInCents / 100);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Fallback if currency is invalid
|
// Fallback if currency is invalid
|
||||||
return `${(costInCents / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
return `${(costInCents / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
||||||
}
|
}
|
||||||
@@ -167,8 +167,8 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<div className="ml-2">
|
<div className="ml-2">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
You're on the free tier with 1,000 emails per month. Upgrade to a paid subscription for
|
You're on the free tier with 1,000 emails per month. Upgrade to a paid subscription for unlimited
|
||||||
unlimited emails or custom limits.
|
emails or custom limits.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -254,9 +254,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Maximum workflow emails per month. Leave empty for unlimited.
|
Maximum workflow emails per month. Leave empty for unlimited.
|
||||||
{estimatedCost && (
|
{estimatedCost && <span className="text-neutral-500"> ≈ {estimatedCost}/month</span>}
|
||||||
<span className="text-neutral-500"> ≈ {estimatedCost}/month</span>
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -286,9 +284,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Maximum campaign emails per month. Leave empty for unlimited.
|
Maximum campaign emails per month. Leave empty for unlimited.
|
||||||
{estimatedCost && (
|
{estimatedCost && <span className="text-neutral-500"> ≈ {estimatedCost}/month</span>}
|
||||||
<span className="text-neutral-500"> ≈ {estimatedCost}/month</span>
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -318,9 +314,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Maximum transactional emails per month. Leave empty for unlimited.
|
Maximum transactional emails per month. Leave empty for unlimited.
|
||||||
{estimatedCost && (
|
{estimatedCost && <span className="text-neutral-500"> ≈ {estimatedCost}/month</span>}
|
||||||
<span className="text-neutral-500"> ≈ {estimatedCost}/month</span>
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -412,7 +406,7 @@ const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: Usa
|
|||||||
<Alert className="mt-3 bg-red-50 border-red-200 text-red-900">
|
<Alert className="mt-3 bg-red-50 border-red-200 text-red-900">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<div className="ml-2">
|
<div className="ml-2">
|
||||||
<p className="text-xs">
|
<p className={'text-sm'}>
|
||||||
<strong>Limit reached:</strong> No more {category.toLowerCase()} emails can be sent this month.
|
<strong>Limit reached:</strong> No more {category.toLowerCase()} emails can be sent this month.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,7 +417,7 @@ const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: Usa
|
|||||||
<Alert className="mt-3 bg-orange-50 border-orange-200 text-orange-900">
|
<Alert className="mt-3 bg-orange-50 border-orange-200 text-orange-900">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<div className="ml-2">
|
<div className="ml-2">
|
||||||
<p className="text-xs">
|
<p className="text-sm">
|
||||||
<strong>Warning:</strong> You've used {Math.round(usage.percentage)}% of your{' '}
|
<strong>Warning:</strong> You've used {Math.round(usage.percentage)}% of your{' '}
|
||||||
{category.toLowerCase()} email limit.
|
{category.toLowerCase()} email limit.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Label,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@plunk/ui';
|
||||||
|
import type {Campaign} from '@plunk/db';
|
||||||
|
import {CampaignStatus} from '@plunk/db';
|
||||||
|
import {ArrowLeft, Calendar, Mail, Users} from 'lucide-react';
|
||||||
|
import {useState} from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
interface PaginatedCampaigns {
|
||||||
|
campaigns: Campaign[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignSelectionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSelectCampaign: (campaign: Campaign, selectedFields: SelectedFields) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedFields {
|
||||||
|
subject: boolean;
|
||||||
|
body: boolean;
|
||||||
|
from: boolean;
|
||||||
|
fromName: boolean;
|
||||||
|
replyTo: boolean;
|
||||||
|
audience: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: CampaignSelectionDialogProps) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||||
|
const [step, setStep] = useState<'select' | 'configure'>('select');
|
||||||
|
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
|
||||||
|
const [selectedFields, setSelectedFields] = useState<SelectedFields>({
|
||||||
|
subject: true,
|
||||||
|
body: true,
|
||||||
|
from: true,
|
||||||
|
fromName: true,
|
||||||
|
replyTo: true,
|
||||||
|
audience: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {data, isLoading} = useSWR<PaginatedCampaigns>(
|
||||||
|
open
|
||||||
|
? `/campaigns?page=${page}&pageSize=10${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`
|
||||||
|
: null,
|
||||||
|
{revalidateOnFocus: false},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCampaignClick = (campaign: Campaign) => {
|
||||||
|
setSelectedCampaign(campaign);
|
||||||
|
setStep('configure');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep('select');
|
||||||
|
setSelectedCampaign(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedCampaign) {
|
||||||
|
onSelectCampaign(selectedCampaign, selectedFields);
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset state
|
||||||
|
setPage(1);
|
||||||
|
setStatusFilter('ALL');
|
||||||
|
setStep('select');
|
||||||
|
setSelectedCampaign(null);
|
||||||
|
setSelectedFields({
|
||||||
|
subject: true,
|
||||||
|
body: true,
|
||||||
|
from: true,
|
||||||
|
fromName: true,
|
||||||
|
replyTo: true,
|
||||||
|
audience: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleField = (field: keyof SelectedFields) => {
|
||||||
|
setSelectedFields(prev => ({...prev, [field]: !prev[field]}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: CampaignStatus) => {
|
||||||
|
const variants: Record<
|
||||||
|
CampaignStatus,
|
||||||
|
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; className?: string}
|
||||||
|
> = {
|
||||||
|
DRAFT: {variant: 'secondary', label: 'Draft', className: 'bg-neutral-100 text-neutral-700'},
|
||||||
|
SCHEDULED: {variant: 'default', label: 'Scheduled', className: 'bg-blue-100 text-blue-700'},
|
||||||
|
SENDING: {variant: 'default', label: 'Sending', className: 'bg-purple-100 text-purple-700'},
|
||||||
|
SENT: {variant: 'default', label: 'Sent', className: 'bg-green-100 text-green-700'},
|
||||||
|
CANCELLED: {variant: 'destructive', label: 'Cancelled', className: 'bg-red-100 text-red-700'},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = variants[status];
|
||||||
|
return (
|
||||||
|
<Badge variant={config.variant} className={config.className}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAudienceLabel = (campaign: Campaign) => {
|
||||||
|
if (campaign.audienceType === 'ALL') {
|
||||||
|
return 'All Contacts';
|
||||||
|
} else if (campaign.audienceType === 'SEGMENT') {
|
||||||
|
return `Segment (${campaign.totalRecipients.toLocaleString()} contacts)`;
|
||||||
|
} else {
|
||||||
|
return `Filtered (${campaign.totalRecipients.toLocaleString()} contacts)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{step === 'configure' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleBack} className="h-8 w-8 p-0">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 'select' ? 'Select a Campaign' : 'Choose What to Copy'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{step === 'select'
|
||||||
|
? 'Choose a previous campaign to use as a starting point'
|
||||||
|
: 'Select which parts of the campaign you want to use'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 'select' ? (
|
||||||
|
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All Statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ALL">All Campaigns</SelectItem>
|
||||||
|
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||||
|
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
|
||||||
|
<SelectItem value="SENDING">Sending</SelectItem>
|
||||||
|
<SelectItem value="SENT">Sent</SelectItem>
|
||||||
|
<SelectItem value="CANCELLED">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campaigns List */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="mt-2 text-sm text-neutral-500">Loading campaigns...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && data?.campaigns.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Mail className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-neutral-900 mb-2">No campaigns found</h3>
|
||||||
|
<p className="text-neutral-500">
|
||||||
|
{statusFilter !== 'ALL' ? 'Try adjusting your filter' : 'Create your first campaign to get started'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.campaigns.map(campaign => (
|
||||||
|
<Card
|
||||||
|
key={campaign.id}
|
||||||
|
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all"
|
||||||
|
onClick={() => handleCampaignClick(campaign)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<CardTitle className="text-base truncate">{campaign.name}</CardTitle>
|
||||||
|
{getStatusBadge(campaign.status)}
|
||||||
|
</div>
|
||||||
|
{campaign.description && (
|
||||||
|
<CardDescription className="text-xs line-clamp-1">{campaign.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="h-3.5 w-3.5 text-neutral-500" />
|
||||||
|
<span className="text-neutral-500">Subject:</span>
|
||||||
|
<span className="font-medium text-neutral-900 truncate">{campaign.subject}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-3.5 w-3.5 text-neutral-500" />
|
||||||
|
<span className="text-neutral-500">Audience:</span>
|
||||||
|
<span className="text-neutral-700">{getAudienceLabel(campaign)}</span>
|
||||||
|
</div>
|
||||||
|
{campaign.scheduledFor && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-3.5 w-3.5 text-neutral-500" />
|
||||||
|
<span className="text-neutral-500">Scheduled:</span>
|
||||||
|
<span className="text-neutral-700">
|
||||||
|
{new Date(campaign.scheduledFor).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data && data.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t">
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Page {page} of {data.totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={page === data.totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6 pr-2">
|
||||||
|
{/* Campaign Preview */}
|
||||||
|
{selectedCampaign && (
|
||||||
|
<Card className="bg-neutral-50">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<CardTitle className="text-base">{selectedCampaign.name}</CardTitle>
|
||||||
|
{getStatusBadge(selectedCampaign.status)}
|
||||||
|
</div>
|
||||||
|
{selectedCampaign.description && (
|
||||||
|
<CardDescription className="text-xs">{selectedCampaign.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Field Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('subject')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="subject"
|
||||||
|
checked={selectedFields.subject}
|
||||||
|
onCheckedChange={() => toggleField('subject')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="subject" className="text-sm font-medium cursor-pointer">
|
||||||
|
Email Subject
|
||||||
|
</Label>
|
||||||
|
{selectedCampaign?.subject && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.subject}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('body')}
|
||||||
|
>
|
||||||
|
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="body" className="text-sm font-medium cursor-pointer">
|
||||||
|
Email Body
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">The full email content and design</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('from')}
|
||||||
|
>
|
||||||
|
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="from" className="text-sm font-medium cursor-pointer">
|
||||||
|
From Email
|
||||||
|
</Label>
|
||||||
|
{selectedCampaign?.from && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.from}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('fromName')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="fromName"
|
||||||
|
checked={selectedFields.fromName}
|
||||||
|
onCheckedChange={() => toggleField('fromName')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="fromName" className="text-sm font-medium cursor-pointer">
|
||||||
|
From Name
|
||||||
|
</Label>
|
||||||
|
{selectedCampaign?.fromName && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.fromName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('replyTo')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="replyTo"
|
||||||
|
checked={selectedFields.replyTo}
|
||||||
|
onCheckedChange={() => toggleField('replyTo')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="replyTo" className="text-sm font-medium cursor-pointer">
|
||||||
|
Reply-To Email
|
||||||
|
</Label>
|
||||||
|
{selectedCampaign?.replyTo && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.replyTo}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('audience')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="audience"
|
||||||
|
checked={selectedFields.audience}
|
||||||
|
onCheckedChange={() => toggleField('audience')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="audience" className="text-sm font-medium cursor-pointer">
|
||||||
|
Audience Settings
|
||||||
|
</Label>
|
||||||
|
{selectedCampaign && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{getAudienceLabel(selectedCampaign)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fixed Action Buttons */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t mt-4">
|
||||||
|
<Button variant="outline" onClick={handleBack} className="flex-1">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!Object.values(selectedFields).some(v => v)}
|
||||||
|
>
|
||||||
|
Create Campaign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -192,7 +192,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
|
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
|
||||||
{project.name.charAt(0).toUpperCase()}
|
{project.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-neutral-900">{project.name}</span>
|
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
|
||||||
{activeProject?.id === project.id && (
|
{activeProject?.id === project.id && (
|
||||||
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
|
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import {TextAlign} from '@tiptap/extension-text-align';
|
|||||||
import {Color} from '@tiptap/extension-color';
|
import {Color} from '@tiptap/extension-color';
|
||||||
import {TextStyle} from '@tiptap/extension-text-style';
|
import {TextStyle} from '@tiptap/extension-text-style';
|
||||||
import {Link} from '@tiptap/extension-link';
|
import {Link} from '@tiptap/extension-link';
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
import {Variable} from './VariableExtension';
|
import {Variable} from './VariableExtension';
|
||||||
import {setAvailableVariables, VariableMention} from './VariableMention';
|
import {setAvailableVariables, VariableMention} from './VariableMention';
|
||||||
import {Toolbar} from './Toolbar';
|
import {Toolbar} from './Toolbar';
|
||||||
import {ResizableImage} from './ResizableImage';
|
import {ResizableImage} from './ResizableImage';
|
||||||
|
import {HtmlEditor} from './HtmlEditor';
|
||||||
import {useContactFields, useContacts} from '../../lib/hooks/useContacts';
|
import {useContactFields, useContacts} from '../../lib/hooks/useContacts';
|
||||||
import {useConfig} from '../../lib/hooks/useConfig';
|
import {useConfig} from '../../lib/hooks/useConfig';
|
||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
@@ -25,7 +27,7 @@ import {
|
|||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
|
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
@@ -153,6 +155,9 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
|||||||
ResizableImage,
|
ResizableImage,
|
||||||
Variable,
|
Variable,
|
||||||
VariableMention,
|
VariableMention,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: placeholder || 'Your next email starts here!',
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
// Only initialize with content if starting in visual mode
|
// Only initialize with content if starting in visual mode
|
||||||
// If starting in HTML mode (due to custom HTML), keep editor empty
|
// If starting in HTML mode (due to custom HTML), keep editor empty
|
||||||
@@ -552,12 +557,11 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="min-h-[400px] max-h-[600px] overflow-y-auto">
|
<div className="min-h-[400px] max-h-[600px] overflow-hidden">
|
||||||
<textarea
|
<HtmlEditor
|
||||||
value={htmlContent}
|
value={htmlContent}
|
||||||
onChange={e => handleHtmlChange(e.target.value)}
|
onChange={handleHtmlChange}
|
||||||
placeholder={placeholder || '<h1>Welcome!</h1><p>Your email content here...</p>'}
|
placeholder={placeholder || 'Your next email starts here!'}
|
||||||
className="w-full h-full px-4 py-3 text-sm font-mono resize-none min-h-[400px] focus:outline-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedContactId && (
|
{selectedContactId && (
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import CodeMirror from '@uiw/react-codemirror';
|
||||||
|
import {html} from '@codemirror/lang-html';
|
||||||
|
import {EditorView} from '@codemirror/view';
|
||||||
|
import {useMemo} from 'react';
|
||||||
|
|
||||||
|
interface HtmlEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HtmlEditor({value, onChange, placeholder}: HtmlEditorProps) {
|
||||||
|
// Custom theme configuration for better integration with the app
|
||||||
|
const theme = EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
minHeight: '400px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
caretColor: '#1f2937',
|
||||||
|
},
|
||||||
|
'.cm-line': {
|
||||||
|
padding: '0',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
color: '#9ca3af',
|
||||||
|
border: 'none',
|
||||||
|
borderRight: '1px solid #e5e7eb',
|
||||||
|
paddingRight: '8px',
|
||||||
|
paddingLeft: '8px',
|
||||||
|
},
|
||||||
|
'.cm-activeLineGutter': {
|
||||||
|
backgroundColor: '#f3f4f6',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
'.cm-activeLine': {
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
},
|
||||||
|
'.cm-selectionBackground, ::selection': {
|
||||||
|
backgroundColor: '#dbeafe !important',
|
||||||
|
},
|
||||||
|
'.cm-focused .cm-selectionBackground, .cm-focused ::selection': {
|
||||||
|
backgroundColor: '#bfdbfe !important',
|
||||||
|
},
|
||||||
|
'.cm-cursor': {
|
||||||
|
borderLeftColor: '#1f2937',
|
||||||
|
},
|
||||||
|
'&.cm-focused': {
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
'.cm-scroller': {
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '600px',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extensions with HTML support and line numbers
|
||||||
|
const extensions = useMemo(
|
||||||
|
() => [
|
||||||
|
html(),
|
||||||
|
theme,
|
||||||
|
EditorView.lineWrapping, // Enable line wrapping for long lines
|
||||||
|
],
|
||||||
|
[theme]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="html-editor-container">
|
||||||
|
<CodeMirror
|
||||||
|
value={value}
|
||||||
|
height="100%"
|
||||||
|
extensions={extensions}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
basicSetup={{
|
||||||
|
lineNumbers: true,
|
||||||
|
highlightActiveLineGutter: true,
|
||||||
|
highlightActiveLine: true,
|
||||||
|
foldGutter: true,
|
||||||
|
dropCursor: true,
|
||||||
|
allowMultipleSelections: true,
|
||||||
|
indentOnInput: true,
|
||||||
|
bracketMatching: true,
|
||||||
|
closeBrackets: true,
|
||||||
|
autocompletion: true,
|
||||||
|
rectangularSelection: true,
|
||||||
|
crosshairCursor: true,
|
||||||
|
highlightSelectionMatches: true,
|
||||||
|
closeBracketsKeymap: true,
|
||||||
|
searchKeymap: true,
|
||||||
|
foldKeymap: true,
|
||||||
|
completionKeymap: true,
|
||||||
|
lintKeymap: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -213,7 +213,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-neutral-200 bg-neutral-50 p-2 flex flex-wrap gap-1 sticky top-0 z-10">
|
<div className="border-b border-neutral-200 bg-neutral-50 p-2 flex flex-wrap gap-1 sticky top-0 z-40">
|
||||||
{/* History */}
|
{/* History */}
|
||||||
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
|
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
|
||||||
<Button
|
<Button
|
||||||
@@ -424,7 +424,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
|
|||||||
</Button>
|
</Button>
|
||||||
{showColorPicker && (
|
{showColorPicker && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-20 max-h-96 overflow-y-auto"
|
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-50 max-h-96 overflow-y-auto"
|
||||||
style={{width: '280px'}}
|
style={{width: '280px'}}
|
||||||
>
|
>
|
||||||
{/* Custom color input */}
|
{/* Custom color input */}
|
||||||
@@ -508,7 +508,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
|
|||||||
<Link className="h-4 w-4" />
|
<Link className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{showLinkInput && (
|
{showLinkInput && (
|
||||||
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-20 min-w-max">
|
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-50 min-w-max">
|
||||||
<div className="flex gap-2 mb-2">
|
<div className="flex gap-2 mb-2">
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
|
|||||||
@@ -28,10 +28,15 @@ export function EmailSettings({
|
|||||||
onReplyToChange,
|
onReplyToChange,
|
||||||
fromPlaceholder = 'hello',
|
fromPlaceholder = 'hello',
|
||||||
fromNamePlaceholder = 'Your Company',
|
fromNamePlaceholder = 'Your Company',
|
||||||
replyToPlaceholder = 'support',
|
replyToPlaceholder,
|
||||||
showFromNameHelpText = false,
|
showFromNameHelpText = false,
|
||||||
layout = 'grid',
|
layout = 'grid',
|
||||||
}: EmailSettingsProps) {
|
}: EmailSettingsProps) {
|
||||||
|
// Use from email's local part as the reply-to placeholder if not provided
|
||||||
|
// Extract local part (before @) since the domain is shown in a dropdown
|
||||||
|
// Fall back to fromPlaceholder when from is empty (since reply-to defaults to from)
|
||||||
|
const fromLocalPart = from.includes('@') ? from.split('@')[0] : from;
|
||||||
|
const effectiveReplyToPlaceholder = replyToPlaceholder || fromLocalPart || fromPlaceholder;
|
||||||
const GridWrapper = layout === 'grid' ? 'div' : 'div';
|
const GridWrapper = layout === 'grid' ? 'div' : 'div';
|
||||||
const gridClassName = layout === 'grid' ? 'grid gap-4 md:grid-cols-2' : 'space-y-4';
|
const gridClassName = layout === 'grid' ? 'grid gap-4 md:grid-cols-2' : 'space-y-4';
|
||||||
|
|
||||||
@@ -73,7 +78,7 @@ export function EmailSettings({
|
|||||||
label="Reply-To Email"
|
label="Reply-To Email"
|
||||||
value={replyTo}
|
value={replyTo}
|
||||||
onChange={onReplyToChange}
|
onChange={onReplyToChange}
|
||||||
placeholder={replyToPlaceholder}
|
placeholder={effectiveReplyToPlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</GridWrapper>
|
</GridWrapper>
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Progress,
|
||||||
|
} from '@plunk/ui';
|
||||||
|
import {AlertCircle, AlertTriangle, CheckCircle, Shield} from 'lucide-react';
|
||||||
|
import type {ProjectSecurityMetrics} from '@plunk/types';
|
||||||
|
|
||||||
|
interface SecuritySettingsProps {
|
||||||
|
metrics: ProjectSecurityMetrics;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Security Overview</CardTitle>
|
||||||
|
<CardDescription>Monitor your project's email health and reputation</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-neutral-500">Loading security metrics...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {status, thresholds, isDisabled} = metrics;
|
||||||
|
|
||||||
|
// Helper to get status color and icon
|
||||||
|
const getStatusIndicator = (rate: number, warningThreshold: number, criticalThreshold: number) => {
|
||||||
|
if (rate >= criticalThreshold) {
|
||||||
|
return {color: 'text-red-600', icon: AlertCircle, bg: 'bg-red-600', label: 'Critical'};
|
||||||
|
}
|
||||||
|
if (rate >= warningThreshold) {
|
||||||
|
return {color: 'text-orange-600', icon: AlertTriangle, bg: 'bg-orange-500', label: 'Warning'};
|
||||||
|
}
|
||||||
|
return {color: 'text-green-600', icon: CheckCircle, bg: 'bg-green-600', label: 'Healthy'};
|
||||||
|
};
|
||||||
|
|
||||||
|
const sevenDayBounceStatus = getStatusIndicator(
|
||||||
|
status.sevenDay.bounceRate,
|
||||||
|
thresholds.BOUNCE_7DAY_WARNING,
|
||||||
|
thresholds.BOUNCE_7DAY_CRITICAL,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allTimeBounceStatus = getStatusIndicator(
|
||||||
|
status.allTime.bounceRate,
|
||||||
|
thresholds.BOUNCE_ALLTIME_WARNING,
|
||||||
|
thresholds.BOUNCE_ALLTIME_CRITICAL,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sevenDayComplaintStatus = getStatusIndicator(
|
||||||
|
status.sevenDay.complaintRate,
|
||||||
|
thresholds.COMPLAINT_7DAY_WARNING,
|
||||||
|
thresholds.COMPLAINT_7DAY_CRITICAL,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allTimeComplaintStatus = getStatusIndicator(
|
||||||
|
status.allTime.complaintRate,
|
||||||
|
thresholds.COMPLAINT_ALLTIME_WARNING,
|
||||||
|
thresholds.COMPLAINT_ALLTIME_CRITICAL,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Overall Status Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${status.isHealthy ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||||
|
<Shield className={`h-5 w-5 ${status.isHealthy ? 'text-green-600' : 'text-red-600'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Security Overview</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{status.isHealthy ? 'Your project is in good standing' : 'Action required to maintain project health'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Project Disabled Alert */}
|
||||||
|
{isDisabled && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Project Disabled</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This project has been disabled due to critical security violations. Contact support to resolve.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Violations */}
|
||||||
|
{status.violations.length > 0 && !isDisabled && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Critical Violations ({status.violations.length})</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm mt-2">
|
||||||
|
{status.violations.map((violation, idx) => (
|
||||||
|
<li key={idx}>{violation}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{status.warnings.length > 0 && (
|
||||||
|
<Alert variant="warning" className="mb-4">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Security Warnings ({status.warnings.length})</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm mt-2">
|
||||||
|
{status.warnings.map((warning, idx) => (
|
||||||
|
<li key={idx}>{warning}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Healthy Status */}
|
||||||
|
{status.isHealthy && !isDisabled && (
|
||||||
|
<Alert>
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertDescription>
|
||||||
|
All security metrics are within acceptable thresholds. Keep up the good work!
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bounce Rate Metrics */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bounce Rate Metrics</CardTitle>
|
||||||
|
<CardDescription>Hard bounces indicate invalid or non-existent email addresses</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 7-Day Bounce Rate */}
|
||||||
|
<MetricDisplay
|
||||||
|
label="7-Day Bounce Rate"
|
||||||
|
rate={status.sevenDay.bounceRate}
|
||||||
|
count={status.sevenDay.bounces}
|
||||||
|
total={status.sevenDay.total}
|
||||||
|
warningThreshold={thresholds.BOUNCE_7DAY_WARNING}
|
||||||
|
criticalThreshold={thresholds.BOUNCE_7DAY_CRITICAL}
|
||||||
|
status={sevenDayBounceStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* All-Time Bounce Rate */}
|
||||||
|
<MetricDisplay
|
||||||
|
label="All-Time Bounce Rate"
|
||||||
|
rate={status.allTime.bounceRate}
|
||||||
|
count={status.allTime.bounces}
|
||||||
|
total={status.allTime.total}
|
||||||
|
warningThreshold={thresholds.BOUNCE_ALLTIME_WARNING}
|
||||||
|
criticalThreshold={thresholds.BOUNCE_ALLTIME_CRITICAL}
|
||||||
|
status={allTimeBounceStatus}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Complaint Rate Metrics */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Complaint Rate Metrics</CardTitle>
|
||||||
|
<CardDescription>Complaints occur when recipients mark emails as spam</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 7-Day Complaint Rate */}
|
||||||
|
<MetricDisplay
|
||||||
|
label="7-Day Complaint Rate"
|
||||||
|
rate={status.sevenDay.complaintRate}
|
||||||
|
count={status.sevenDay.complaints}
|
||||||
|
total={status.sevenDay.total}
|
||||||
|
warningThreshold={thresholds.COMPLAINT_7DAY_WARNING}
|
||||||
|
criticalThreshold={thresholds.COMPLAINT_7DAY_CRITICAL}
|
||||||
|
status={sevenDayComplaintStatus}
|
||||||
|
isComplaintRate
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* All-Time Complaint Rate */}
|
||||||
|
<MetricDisplay
|
||||||
|
label="All-Time Complaint Rate"
|
||||||
|
rate={status.allTime.complaintRate}
|
||||||
|
count={status.allTime.complaints}
|
||||||
|
total={status.allTime.total}
|
||||||
|
warningThreshold={thresholds.COMPLAINT_ALLTIME_WARNING}
|
||||||
|
criticalThreshold={thresholds.COMPLAINT_ALLTIME_CRITICAL}
|
||||||
|
status={allTimeComplaintStatus}
|
||||||
|
isComplaintRate
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricDisplayProps {
|
||||||
|
label: string;
|
||||||
|
rate: number;
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
warningThreshold: number;
|
||||||
|
criticalThreshold: number;
|
||||||
|
status: {
|
||||||
|
color: string;
|
||||||
|
icon: React.ComponentType<{className?: string}>;
|
||||||
|
bg: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
isComplaintRate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricDisplay({
|
||||||
|
label,
|
||||||
|
rate,
|
||||||
|
count,
|
||||||
|
total,
|
||||||
|
warningThreshold,
|
||||||
|
criticalThreshold,
|
||||||
|
status,
|
||||||
|
isComplaintRate = false,
|
||||||
|
}: MetricDisplayProps) {
|
||||||
|
const Icon = status.icon;
|
||||||
|
const progressValue = Math.min((rate / criticalThreshold) * 100, 100);
|
||||||
|
const decimals = isComplaintRate ? 3 : 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-neutral-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-neutral-900">{label}</h3>
|
||||||
|
<p className="text-sm text-neutral-600 mt-1">
|
||||||
|
{count.toLocaleString()} / {total.toLocaleString()} emails
|
||||||
|
<span className="text-neutral-400 mx-2">•</span>
|
||||||
|
<strong>{rate.toFixed(decimals)}%</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-2 ${status.color}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">{status.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Progress value={progressValue} className="h-2" indicatorClassName={status.bg} />
|
||||||
|
<div className="flex justify-between text-xs text-neutral-500">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>Warning: {warningThreshold}%</span>
|
||||||
|
<span>Critical: {criticalThreshold}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import {Alert, AlertDescription, AlertTitle, Button} from '@plunk/ui';
|
||||||
|
import {AlertTriangle} from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type {SecurityStatus} from '@plunk/types';
|
||||||
|
|
||||||
|
interface SecurityWarningBannerProps {
|
||||||
|
status: SecurityStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
|
||||||
|
// Don't show if no warnings or already disabled
|
||||||
|
if (status.warnings.length === 0 || status.shouldDisable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Security Warning - Action Required</AlertTitle>
|
||||||
|
<AlertDescription className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Your project has exceeded the following security thresholds:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm text-amber-800">
|
||||||
|
{status.warnings.map((warning, idx) => (
|
||||||
|
<li key={idx}>{warning}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-amber-800 mt-2">
|
||||||
|
High bounce or complaint rates can lead to project suspension. Review the detailed metrics
|
||||||
|
and take action to improve your email quality.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/settings?tab=security">
|
||||||
|
<Button size="sm" variant="outline" className="w-full sm:w-auto flex-shrink-0">
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
Label
|
||||||
|
} from '@plunk/ui';
|
||||||
|
import type {Template} from '@plunk/db';
|
||||||
|
import {ArrowLeft, FileText, Search} from 'lucide-react';
|
||||||
|
import {useState} from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
interface PaginatedTemplates {
|
||||||
|
templates: Template[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateSelectionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSelectTemplate: (template: Template, selectedFields: SelectedFields) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedFields {
|
||||||
|
subject: boolean;
|
||||||
|
body: boolean;
|
||||||
|
from: boolean;
|
||||||
|
fromName: boolean;
|
||||||
|
replyTo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}: TemplateSelectionDialogProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [typeFilter, setTypeFilter] = useState<'ALL' | 'TRANSACTIONAL' | 'MARKETING'>('ALL');
|
||||||
|
const [step, setStep] = useState<'select' | 'configure'>('select');
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||||
|
const [selectedFields, setSelectedFields] = useState<SelectedFields>({
|
||||||
|
subject: true,
|
||||||
|
body: true,
|
||||||
|
from: true,
|
||||||
|
fromName: true,
|
||||||
|
replyTo: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {data, isLoading} = useSWR<PaginatedTemplates>(
|
||||||
|
open
|
||||||
|
? `/templates?page=${page}&pageSize=10${search ? `&search=${search}` : ''}${typeFilter !== 'ALL' ? `&type=${typeFilter}` : ''}`
|
||||||
|
: null,
|
||||||
|
{revalidateOnFocus: false},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearch(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemplateClick = (template: Template) => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setStep('configure');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep('select');
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedTemplate) {
|
||||||
|
onSelectTemplate(selectedTemplate, selectedFields);
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset state
|
||||||
|
setSearch('');
|
||||||
|
setSearchInput('');
|
||||||
|
setPage(1);
|
||||||
|
setTypeFilter('ALL');
|
||||||
|
setStep('select');
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
setSelectedFields({
|
||||||
|
subject: true,
|
||||||
|
body: true,
|
||||||
|
from: true,
|
||||||
|
fromName: true,
|
||||||
|
replyTo: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleField = (field: keyof SelectedFields) => {
|
||||||
|
setSelectedFields(prev => ({...prev, [field]: !prev[field]}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{step === 'configure' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleBack} className="h-8 w-8 p-0">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 'select' ? 'Select a Template' : 'Choose What to Copy'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{step === 'select'
|
||||||
|
? 'Choose a template to start your campaign with'
|
||||||
|
: 'Select which parts of the template you want to use'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 'select' ? (
|
||||||
|
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search templates..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
{search && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSearch('');
|
||||||
|
setSearchInput('');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Type Filter */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTypeFilter('ALL');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
variant={typeFilter === 'ALL' ? 'default' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
All Templates
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTypeFilter('MARKETING');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
variant={typeFilter === 'MARKETING' ? 'default' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Marketing
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTypeFilter('TRANSACTIONAL');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
variant={typeFilter === 'TRANSACTIONAL' ? 'default' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Transactional
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Templates List */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="mt-2 text-sm text-neutral-500">Loading templates...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && data?.templates.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<FileText className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-neutral-900 mb-2">No templates found</h3>
|
||||||
|
<p className="text-neutral-500">
|
||||||
|
{search ? 'Try adjusting your search terms' : 'Create a template first to use this feature'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.templates.map(template => (
|
||||||
|
<Card
|
||||||
|
key={template.id}
|
||||||
|
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all"
|
||||||
|
onClick={() => handleTemplateClick(template)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<CardTitle className="text-base truncate">{template.name}</CardTitle>
|
||||||
|
<Badge className="capitalize" variant={template.type === 'MARKETING' ? 'info' : 'success'}>
|
||||||
|
{template.type.toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{template.description && (
|
||||||
|
<CardDescription className="text-xs line-clamp-1">{template.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-neutral-500">Subject:</span>{' '}
|
||||||
|
<span className="font-medium text-neutral-900">{template.subject}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-neutral-500">From:</span>{' '}
|
||||||
|
<span className="text-neutral-700">{template.from}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data && data.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t">
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Page {page} of {data.totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={page === data.totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6 pr-2">
|
||||||
|
{/* Template Preview */}
|
||||||
|
{selectedTemplate && (
|
||||||
|
<Card className="bg-neutral-50">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<CardTitle className="text-base">{selectedTemplate.name}</CardTitle>
|
||||||
|
<Badge
|
||||||
|
className="capitalize"
|
||||||
|
variant={selectedTemplate.type === 'MARKETING' ? 'info' : 'success'}
|
||||||
|
>
|
||||||
|
{selectedTemplate.type.toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{selectedTemplate.description && (
|
||||||
|
<CardDescription className="text-xs">{selectedTemplate.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Field Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('subject')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="subject"
|
||||||
|
checked={selectedFields.subject}
|
||||||
|
onCheckedChange={() => toggleField('subject')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="subject" className="text-sm font-medium cursor-pointer">
|
||||||
|
Email Subject
|
||||||
|
</Label>
|
||||||
|
{selectedTemplate?.subject && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.subject}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('body')}
|
||||||
|
>
|
||||||
|
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="body" className="text-sm font-medium cursor-pointer">
|
||||||
|
Email Body
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">The full email content and design</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('from')}
|
||||||
|
>
|
||||||
|
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="from" className="text-sm font-medium cursor-pointer">
|
||||||
|
From Email
|
||||||
|
</Label>
|
||||||
|
{selectedTemplate?.from && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.from}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('fromName')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="fromName"
|
||||||
|
checked={selectedFields.fromName}
|
||||||
|
onCheckedChange={() => toggleField('fromName')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="fromName" className="text-sm font-medium cursor-pointer">
|
||||||
|
From Name
|
||||||
|
</Label>
|
||||||
|
{selectedTemplate?.fromName && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.fromName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => toggleField('replyTo')}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="replyTo"
|
||||||
|
checked={selectedFields.replyTo}
|
||||||
|
onCheckedChange={() => toggleField('replyTo')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="replyTo" className="text-sm font-medium cursor-pointer">
|
||||||
|
Reply-To Email
|
||||||
|
</Label>
|
||||||
|
{selectedTemplate?.replyTo && (
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.replyTo}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fixed Action Buttons */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t mt-4">
|
||||||
|
<Button variant="outline" onClick={handleBack} className="flex-1">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm} className="flex-1" disabled={!Object.values(selectedFields).some(v => v)}>
|
||||||
|
Create Campaign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* Shared date formatting and manipulation utilities
|
* Shared date formatting and manipulation utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the user's timezone
|
* Get the user's timezone
|
||||||
*/
|
*/
|
||||||
@@ -106,3 +108,21 @@ export function formatUTCDateTime(date: Date): string {
|
|||||||
timeZone: 'UTC',
|
timeZone: 'UTC',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date as relative time with short format for very recent updates
|
||||||
|
* Returns "just now" for updates within the last minute to avoid text wrapping
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(date: Date | string): string {
|
||||||
|
const now = dayjs();
|
||||||
|
const then = dayjs(date);
|
||||||
|
const secondsAgo = now.diff(then, 'second');
|
||||||
|
|
||||||
|
// If less than 60 seconds, show "just now"
|
||||||
|
if (secondsAgo < 60) {
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use standard relative time
|
||||||
|
return then.fromNow();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import useSWR from 'swr';
|
||||||
|
import type {ProjectSecurityMetrics} from '@plunk/types';
|
||||||
|
|
||||||
|
export interface ProjectSecurityResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: ProjectSecurityMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch project security metrics
|
||||||
|
* Auto-refreshes every 2 minutes to keep data current
|
||||||
|
*/
|
||||||
|
export function useProjectSecurity(projectId: string | undefined) {
|
||||||
|
const {data, error, isLoading, mutate} = useSWR<ProjectSecurityResponse>(
|
||||||
|
projectId ? `/projects/${projectId}/security` : null,
|
||||||
|
{
|
||||||
|
refreshInterval: 120000, // 2 minutes
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
securityMetrics: data?.data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,9 +10,16 @@ import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
|
|||||||
import {useProjects} from '../lib/hooks/useProject';
|
import {useProjects} from '../lib/hooks/useProject';
|
||||||
import {useUser} from '../lib/hooks/useUser';
|
import {useUser} from '../lib/hooks/useUser';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||||
|
|
||||||
|
// Configure dayjs plugins globally
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.extend(advancedFormat);
|
||||||
|
|
||||||
// Routes that don't require authentication
|
// Routes that don't require authentication
|
||||||
const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset'];
|
const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset', '/unsubscribe', '/subscribe', '/manage'];
|
||||||
|
|
||||||
// Routes that don't require a project
|
// Routes that don't require a project
|
||||||
const NO_PROJECT_ROUTES = ['/projects/create'];
|
const NO_PROJECT_ROUTES = ['/projects/create'];
|
||||||
@@ -28,7 +35,9 @@ function App({Component, pageProps}: AppProps) {
|
|||||||
function AuthGuard({children}: {children: React.ReactNode}) {
|
function AuthGuard({children}: {children: React.ReactNode}) {
|
||||||
const {data: user, isLoading} = useUser();
|
const {data: user, isLoading} = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isPublicRoute = PUBLIC_ROUTES.includes(router.pathname);
|
const isPublicRoute = PUBLIC_ROUTES.some(route =>
|
||||||
|
router.pathname === route || router.pathname.startsWith(`${route}/`)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If not loading, no user, and trying to access a protected route, redirect to login
|
// If not loading, no user, and trying to access a protected route, redirect to login
|
||||||
@@ -102,7 +111,9 @@ export default function WithProviders(props: AppProps) {
|
|||||||
|
|
||||||
function Root(props: AppProps) {
|
function Root(props: AppProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isPublicRoute = PUBLIC_ROUTES.includes(router.pathname);
|
const isPublicRoute = PUBLIC_ROUTES.some(route =>
|
||||||
|
router.pathname === route || router.pathname.startsWith(`${route}/`)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Calendar,
|
Calendar,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Info,
|
||||||
Mail,
|
Mail,
|
||||||
MousePointer,
|
MousePointer,
|
||||||
Save,
|
Save,
|
||||||
@@ -86,7 +87,7 @@ export default function CampaignDetailsPage() {
|
|||||||
id && campaign?.data.status !== CampaignStatus.DRAFT ? `/campaigns/${id}/stats` : null,
|
id && campaign?.data.status !== CampaignStatus.DRAFT ? `/campaigns/${id}/stats` : null,
|
||||||
{
|
{
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
refreshInterval: campaign?.data.status === CampaignStatus.SENDING ? 5000 : 0, // Refresh every 5s if sending
|
refreshInterval: campaign?.data.status === CampaignStatus.SENDING ? 15000 : 0, // Refresh every 15s while sending
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ export default function CampaignDetailsPage() {
|
|||||||
|
|
||||||
// Fetch project members for test email
|
// Fetch project members for test email
|
||||||
const {data: projectMembers} = useSWR<{data: Array<{userId: string; email: string; role: string}>}>(
|
const {data: projectMembers} = useSWR<{data: Array<{userId: string; email: string; role: string}>}>(
|
||||||
id ? `/projects/${campaign?.data.projectId}/members` : null,
|
id && campaign?.data.projectId ? `/projects/${campaign.data.projectId}/members` : null,
|
||||||
{revalidateOnFocus: false},
|
{revalidateOnFocus: false},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -213,14 +214,28 @@ export default function CampaignDetailsPage() {
|
|||||||
subject: editedCampaign.subject,
|
subject: editedCampaign.subject,
|
||||||
body: editedCampaign.body,
|
body: editedCampaign.body,
|
||||||
from: editedCampaign.from,
|
from: editedCampaign.from,
|
||||||
fromName: editedCampaign.fromName || undefined,
|
fromName: editedCampaign.fromName || null,
|
||||||
replyTo: editedCampaign.replyTo || undefined,
|
replyTo: editedCampaign.replyTo || null,
|
||||||
audienceType: editedCampaign.audienceType,
|
audienceType: editedCampaign.audienceType,
|
||||||
segmentId: editedCampaign.segmentId || undefined,
|
segmentId: editedCampaign.segmentId || undefined,
|
||||||
});
|
});
|
||||||
// Silent save - no toast notification
|
// Silent save - no toast notification
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
void mutate();
|
// Refetch and re-sync the edited campaign with fresh data
|
||||||
|
const updated = await mutate();
|
||||||
|
if (updated?.data) {
|
||||||
|
setEditedCampaign({
|
||||||
|
name: updated.data.name,
|
||||||
|
description: updated.data.description || '',
|
||||||
|
subject: updated.data.subject,
|
||||||
|
body: updated.data.body,
|
||||||
|
from: updated.data.from,
|
||||||
|
fromName: updated.data.fromName || '',
|
||||||
|
replyTo: updated.data.replyTo || '',
|
||||||
|
audienceType: updated.data.audienceType,
|
||||||
|
segmentId: updated.data.segmentId || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to update campaign');
|
toast.error(error instanceof Error ? error.message : 'Failed to update campaign');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -312,19 +327,9 @@ export default function CampaignDetailsPage() {
|
|||||||
const c = campaign.data;
|
const c = campaign.data;
|
||||||
const s = stats?.data;
|
const s = stats?.data;
|
||||||
|
|
||||||
// Get recipient count for draft campaigns
|
// Get recipient count for draft campaigns from the campaign's totalRecipients field
|
||||||
const getDraftRecipientCount = () => {
|
// The backend calculates this for all audience types when the campaign is created/updated
|
||||||
if (!campaign?.data) return 0;
|
const draftRecipientCount = isEditMode && campaign?.data ? campaign.data.totalRecipients : 0;
|
||||||
const c = campaign.data;
|
|
||||||
|
|
||||||
if (c.audienceType === CampaignAudienceType.SEGMENT && c.segmentId && segments) {
|
|
||||||
const segment = segments.find(s => s.id === c.segmentId);
|
|
||||||
return segment?.memberCount || 0;
|
|
||||||
}
|
|
||||||
return 0; // We'd need total contact count for ALL audience type
|
|
||||||
};
|
|
||||||
|
|
||||||
const draftRecipientCount = isEditMode ? getDraftRecipientCount() : 0;
|
|
||||||
|
|
||||||
// Render edit form for drafts
|
// Render edit form for drafts
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
@@ -386,18 +391,37 @@ export default function CampaignDetailsPage() {
|
|||||||
<ChevronDown className="h-4 w-4 sm:ml-1" />
|
<ChevronDown className="h-4 w-4 sm:ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-72">
|
||||||
<DropdownMenuItem onClick={() => setIsTestEmailDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setIsTestEmailDialogOpen(true)} className="py-3 cursor-pointer">
|
||||||
<TestTube className="h-4 w-4 mr-2" />
|
<div className="flex items-start gap-3">
|
||||||
Send Test Email
|
<TestTube className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">Send Test Email</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Preview in your inbox before sending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setShowSendDialog(true)}>
|
<DropdownMenuItem onClick={() => setShowSendDialog(true)} className="py-3 cursor-pointer">
|
||||||
<Send className="h-4 w-4 mr-2" />
|
<div className="flex items-start gap-3">
|
||||||
Send Now
|
<Send className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">Send Now</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Send immediately to all recipients
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setIsScheduleDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setIsScheduleDialogOpen(true)} className="py-3 cursor-pointer">
|
||||||
<Calendar className="h-4 w-4 mr-2" />
|
<div className="flex items-start gap-3">
|
||||||
Schedule for Later
|
<Calendar className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">Schedule for Later</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">Choose a specific date and time</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -497,11 +521,6 @@ export default function CampaignDetailsPage() {
|
|||||||
title="Segment"
|
title="Segment"
|
||||||
description="Target a defined group of contacts"
|
description="Target a defined group of contacts"
|
||||||
/>
|
/>
|
||||||
<SelectItemWithDescription
|
|
||||||
value={CampaignAudienceType.FILTERED}
|
|
||||||
title="Filtered"
|
|
||||||
description="Use advanced filter conditions"
|
|
||||||
/>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,13 +570,20 @@ export default function CampaignDetailsPage() {
|
|||||||
|
|
||||||
{/* Show recipient count */}
|
{/* Show recipient count */}
|
||||||
{draftRecipientCount > 0 && (
|
{draftRecipientCount > 0 && (
|
||||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="h-4 w-4 text-blue-600" />
|
<Users className="h-4 w-4 text-blue-600" />
|
||||||
<span className="text-sm font-medium text-blue-900">
|
<span className="text-sm font-medium text-blue-900">
|
||||||
{draftRecipientCount.toLocaleString()} recipients
|
{draftRecipientCount.toLocaleString()} recipients
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-blue-800">
|
||||||
|
This count will be recalculated right before sending to ensure accuracy. The final number may
|
||||||
|
differ if contacts subscribe, unsubscribe, or segment membership changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -565,7 +591,7 @@ export default function CampaignDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Editor - Full Width */}
|
{/* Email Editor - Full Width */}
|
||||||
<Card>
|
<Card className="overflow-visible">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Email Content</CardTitle>
|
<CardTitle>Email Content</CardTitle>
|
||||||
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
|
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
|
||||||
@@ -577,7 +603,6 @@ export default function CampaignDetailsPage() {
|
|||||||
setEditedCampaign({...editedCampaign, body});
|
setEditedCampaign({...editedCampaign, body});
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
}}
|
}}
|
||||||
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -972,6 +997,7 @@ export default function CampaignDetailsPage() {
|
|||||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Scheduled For</p>
|
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Scheduled For</p>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Calendar className="h-4 w-4 text-neutral-400 mt-0.5" />
|
<Calendar className="h-4 w-4 text-neutral-400 mt-0.5" />
|
||||||
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-900">
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
{formatFullDateTime(new Date(c.scheduledFor))}
|
{formatFullDateTime(new Date(c.scheduledFor))}
|
||||||
@@ -980,6 +1006,13 @@ export default function CampaignDetailsPage() {
|
|||||||
UTC: {formatUTCDateTime(new Date(c.scheduledFor))}
|
UTC: {formatUTCDateTime(new Date(c.scheduledFor))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{c.status === CampaignStatus.SCHEDULED && (
|
||||||
|
<div className="flex items-start gap-1.5 p-2 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<Info className="h-3 w-3 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-blue-800">Recipient count will be recalculated at send time</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -987,21 +1020,15 @@ export default function CampaignDetailsPage() {
|
|||||||
{/* Sent At */}
|
{/* Sent At */}
|
||||||
{c.sentAt && (
|
{c.sentAt && (
|
||||||
<div className="pb-3 border-b border-neutral-100">
|
<div className="pb-3 border-b border-neutral-100">
|
||||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent At</p>
|
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent On</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Send className="h-4 w-4 text-neutral-400" />
|
<Send className="h-4 w-4 text-neutral-400" />
|
||||||
<p className="text-sm font-medium text-neutral-900">
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
{new Date(c.sentAt).toLocaleDateString()} at {new Date(c.sentAt).toLocaleTimeString()}
|
{formatFullDateTime(new Date(c.sentAt))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Created */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Created</p>
|
|
||||||
<p className="text-sm text-neutral-900">{new Date(c.createdAt).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {EmailFormValidator} from '../../lib/validation';
|
|||||||
import {ArrowLeft, Save, Users} from 'lucide-react';
|
import {ArrowLeft, Save, Users} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||||
@@ -43,9 +43,92 @@ export default function CreateCampaignPage() {
|
|||||||
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
|
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
|
||||||
const [segmentId, setSegmentId] = useState('');
|
const [segmentId, setSegmentId] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [loadingTemplate, setLoadingTemplate] = useState(false);
|
||||||
|
|
||||||
const {data: segments} = useSWR<Segment[]>('/segments', {revalidateOnFocus: false});
|
const {data: segments} = useSWR<Segment[]>('/segments', {revalidateOnFocus: false});
|
||||||
|
|
||||||
|
// Load template or campaign data if provided in query params
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
const {
|
||||||
|
templateId,
|
||||||
|
campaignId,
|
||||||
|
name: queryName,
|
||||||
|
subject: querySubject,
|
||||||
|
from: queryFrom,
|
||||||
|
fromName: queryFromName,
|
||||||
|
replyTo: queryReplyTo,
|
||||||
|
audienceType: queryAudienceType,
|
||||||
|
segmentId: querySegmentId,
|
||||||
|
} = router.query;
|
||||||
|
|
||||||
|
// Handle template loading
|
||||||
|
if (templateId && typeof templateId === 'string') {
|
||||||
|
setLoadingTemplate(true);
|
||||||
|
try {
|
||||||
|
// Fetch the full template to get the body content
|
||||||
|
const template = await network.fetch<{data: {body: string}}>('GET', `/templates/${templateId}`);
|
||||||
|
|
||||||
|
// Pre-fill form with template data
|
||||||
|
if (queryName && typeof queryName === 'string') setName(queryName);
|
||||||
|
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
|
||||||
|
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
|
||||||
|
if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
|
||||||
|
if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
|
||||||
|
setBody(template.data.body);
|
||||||
|
|
||||||
|
toast.success('Template loaded successfully');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load template');
|
||||||
|
} finally {
|
||||||
|
setLoadingTemplate(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle campaign loading
|
||||||
|
else if (campaignId && typeof campaignId === 'string') {
|
||||||
|
setLoadingTemplate(true);
|
||||||
|
try {
|
||||||
|
// Fetch the full campaign to get the body content
|
||||||
|
const campaign = await network.fetch<{data: {body: string}}>('GET', `/campaigns/${campaignId}`);
|
||||||
|
|
||||||
|
// Pre-fill form with campaign data
|
||||||
|
if (queryName && typeof queryName === 'string') setName(queryName);
|
||||||
|
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
|
||||||
|
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
|
||||||
|
if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
|
||||||
|
if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
|
||||||
|
if (queryAudienceType && typeof queryAudienceType === 'string') {
|
||||||
|
setAudienceType(queryAudienceType as CampaignAudienceType);
|
||||||
|
}
|
||||||
|
if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId);
|
||||||
|
setBody(campaign.data.body);
|
||||||
|
|
||||||
|
toast.success('Campaign loaded successfully');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load campaign');
|
||||||
|
} finally {
|
||||||
|
setLoadingTemplate(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle query params without template/campaign ID (direct field values)
|
||||||
|
else {
|
||||||
|
if (queryName && typeof queryName === 'string') setName(queryName);
|
||||||
|
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
|
||||||
|
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
|
||||||
|
if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
|
||||||
|
if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
|
||||||
|
if (queryAudienceType && typeof queryAudienceType === 'string') {
|
||||||
|
setAudienceType(queryAudienceType as CampaignAudienceType);
|
||||||
|
}
|
||||||
|
if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (router.isReady) {
|
||||||
|
void loadData();
|
||||||
|
}
|
||||||
|
}, [router.isReady, router.query]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -64,8 +147,8 @@ export default function CreateCampaignPage() {
|
|||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
from,
|
from,
|
||||||
fromName: fromName || undefined,
|
fromName: fromName || null,
|
||||||
replyTo: replyTo || undefined,
|
replyTo: replyTo || null,
|
||||||
audienceType,
|
audienceType,
|
||||||
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
|
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
|
||||||
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
|
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
|
||||||
@@ -94,6 +177,26 @@ export default function CreateCampaignPage() {
|
|||||||
<>
|
<>
|
||||||
<NextSeo title="Create Campaign" />
|
<NextSeo title="Create Campaign" />
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
|
{loadingTemplate && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-lg p-6 text-center">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 animate-spin mx-auto text-neutral-900 mb-3"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-neutral-700">Loading template...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 sm:gap-4">
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
@@ -188,7 +291,7 @@ export default function CreateCampaignPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Email Content */}
|
{/* Email Content */}
|
||||||
<Card>
|
<Card className="overflow-visible">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" />
|
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -197,11 +300,7 @@ export default function CreateCampaignPage() {
|
|||||||
<Label htmlFor="body">
|
<Label htmlFor="body">
|
||||||
Email Body <span className="text-red-500">*</span>
|
Email Body <span className="text-red-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<EmailEditor
|
<EmailEditor value={body} onChange={setBody} />
|
||||||
value={body}
|
|
||||||
onChange={setBody}
|
|
||||||
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -283,6 +382,18 @@ export default function CreateCampaignPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{audienceType === CampaignAudienceType.ALL && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-900">All subscribed contacts</p>
|
||||||
|
<p className="text-xs text-blue-700 mt-1">
|
||||||
|
This campaign will be sent to all contacts who haven't unsubscribed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,6 +450,13 @@ export default function CreateCampaignPage() {
|
|||||||
<span className="font-medium">{estimatedRecipients.toLocaleString()}</span>
|
<span className="font-medium">{estimatedRecipients.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{audienceType === CampaignAudienceType.ALL && (
|
||||||
|
<div className="flex justify-between py-2">
|
||||||
|
<span className="text-neutral-500">Recipients</span>
|
||||||
|
<span className="font-medium">All subscribed</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Note */}
|
{/* Info Note */}
|
||||||
|
|||||||
@@ -7,22 +7,31 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import type {Campaign} from '@plunk/db';
|
import type {Campaign, Template} from '@plunk/db';
|
||||||
import {CampaignStatus} from '@plunk/db';
|
import {CampaignStatus} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
|
import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog';
|
||||||
|
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {Calendar, Copy, Mail, Plus, Trash2, Users} from 'lucide-react';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
|
import {Calendar, ChevronDown, Copy, FileText, Info, Mail, Plus, RefreshCw, Trash2, Users} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import {useRouter} from 'next/router';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface PaginatedCampaigns {
|
interface PaginatedCampaigns {
|
||||||
campaigns: Campaign[];
|
campaigns: Campaign[];
|
||||||
@@ -33,12 +42,15 @@ interface PaginatedCampaigns {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CampaignsPage() {
|
export default function CampaignsPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||||
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
|
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [campaignToDelete, setCampaignToDelete] = useState<string | null>(null);
|
const [campaignToDelete, setCampaignToDelete] = useState<string | null>(null);
|
||||||
|
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
|
||||||
|
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
|
||||||
|
|
||||||
const {data, mutate, isLoading} = useSWR<PaginatedCampaigns>(
|
const {data, mutate, isLoading} = useSWR<PaginatedCampaigns>(
|
||||||
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
|
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
|
||||||
@@ -103,6 +115,93 @@ export default function CampaignsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectTemplate = (
|
||||||
|
template: Template,
|
||||||
|
selectedFields: {
|
||||||
|
subject: boolean;
|
||||||
|
body: boolean;
|
||||||
|
from: boolean;
|
||||||
|
fromName: boolean;
|
||||||
|
replyTo: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
// Navigate to create page with template data as query params
|
||||||
|
const query: Record<string, string> = {
|
||||||
|
name: `${template.name}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include templateId if body is selected (needed to fetch body content)
|
||||||
|
if (selectedFields.body) {
|
||||||
|
query.templateId = template.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add selected fields to query params
|
||||||
|
if (selectedFields.subject) {
|
||||||
|
query.subject = template.subject;
|
||||||
|
}
|
||||||
|
if (selectedFields.from) {
|
||||||
|
query.from = template.from;
|
||||||
|
}
|
||||||
|
if (selectedFields.fromName && template.fromName) {
|
||||||
|
query.fromName = template.fromName;
|
||||||
|
}
|
||||||
|
if (selectedFields.replyTo && template.replyTo) {
|
||||||
|
query.replyTo = template.replyTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void router.push({
|
||||||
|
pathname: '/campaigns/create',
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectCampaign = (
|
||||||
|
campaign: Campaign,
|
||||||
|
selectedFields: {
|
||||||
|
subject: boolean;
|
||||||
|
body: boolean;
|
||||||
|
from: boolean;
|
||||||
|
fromName: boolean;
|
||||||
|
replyTo: boolean;
|
||||||
|
audience: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
// Navigate to create page with campaign data as query params
|
||||||
|
const query: Record<string, string> = {
|
||||||
|
name: `${campaign.name}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include campaignId if body is selected (needed to fetch body content)
|
||||||
|
if (selectedFields.body) {
|
||||||
|
query.campaignId = campaign.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add selected fields to query params
|
||||||
|
if (selectedFields.subject) {
|
||||||
|
query.subject = campaign.subject;
|
||||||
|
}
|
||||||
|
if (selectedFields.from) {
|
||||||
|
query.from = campaign.from;
|
||||||
|
}
|
||||||
|
if (selectedFields.fromName && campaign.fromName) {
|
||||||
|
query.fromName = campaign.fromName;
|
||||||
|
}
|
||||||
|
if (selectedFields.replyTo && campaign.replyTo) {
|
||||||
|
query.replyTo = campaign.replyTo;
|
||||||
|
}
|
||||||
|
if (selectedFields.audience) {
|
||||||
|
query.audienceType = campaign.audienceType;
|
||||||
|
if (campaign.segmentId) {
|
||||||
|
query.segmentId = campaign.segmentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void router.push({
|
||||||
|
pathname: '/campaigns/create',
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo title="Campaigns" />
|
<NextSeo title="Campaigns" />
|
||||||
@@ -116,13 +215,51 @@ export default function CampaignsPage() {
|
|||||||
Send one-time email broadcasts to your contacts. {data?.total ? `${data.total} total campaigns` : ''}
|
Send one-time email broadcasts to your contacts. {data?.total ? `${data.total} total campaigns` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/campaigns/create" className="w-full sm:w-auto">
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<Button className="w-full sm:w-auto">
|
<Button className="w-full sm:w-auto">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Create Campaign</span>
|
<span className="hidden sm:inline">Create Campaign</span>
|
||||||
<span className="sm:hidden">Create</span>
|
<span className="sm:hidden">Create</span>
|
||||||
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-80">
|
||||||
|
<DropdownMenuItem asChild className="py-3 cursor-pointer">
|
||||||
|
<Link href="/campaigns/create" className="flex items-start gap-3">
|
||||||
|
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">Empty Campaign</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Start from scratch with a blank canvas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">From Template</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Use an existing template as a starting point
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">From Previous Campaign</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Copy content and settings from an existing campaign
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
@@ -171,12 +308,60 @@ export default function CampaignsPage() {
|
|||||||
? 'Try adjusting your filters or create a new campaign.'
|
? 'Try adjusting your filters or create a new campaign.'
|
||||||
: 'Create your first campaign to send emails to your contacts.'}
|
: 'Create your first campaign to send emails to your contacts.'}
|
||||||
</p>
|
</p>
|
||||||
<Link href="/campaigns/create">
|
{statusFilter === 'ALL' && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<Button size="lg">
|
<Button size="lg">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Create Your First Campaign
|
Create Your First Campaign
|
||||||
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="w-80">
|
||||||
|
<DropdownMenuItem asChild className="py-3 cursor-pointer">
|
||||||
|
<Link href="/campaigns/create" className="flex items-start gap-3">
|
||||||
|
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">Empty Campaign</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Start from scratch with a blank canvas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">From Template</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Use an existing template as a starting point
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">From Previous Campaign</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Copy content and settings from an existing campaign
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
{statusFilter !== 'ALL' && (
|
||||||
|
<Link href="/campaigns/create">
|
||||||
|
<Button size="lg">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Campaign
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -216,11 +401,20 @@ export default function CampaignsPage() {
|
|||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Users className="h-3.5 w-3.5 text-blue-600" />
|
<Users className="h-3.5 w-3.5 text-blue-600" />
|
||||||
<span className="text-xs font-medium text-blue-900">Recipients</span>
|
<span className="text-xs font-medium text-blue-900">Recipients</span>
|
||||||
|
{(campaign.status === 'DRAFT' || campaign.status === 'SCHEDULED') && (
|
||||||
|
<div className="group relative">
|
||||||
|
<Info className="h-3 w-3 text-blue-600 cursor-help" />
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1">
|
||||||
|
This count will be recalculated before sending
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold text-blue-900">{campaign.totalRecipients.toLocaleString()}</p>
|
<p className="text-lg font-bold text-blue-900">{campaign.totalRecipients.toLocaleString()}</p>
|
||||||
{campaign.totalRecipients > 0 && (
|
{campaign.totalRecipients > 0 && campaign.status !== 'DRAFT' && (
|
||||||
<p className="text-xs text-blue-700 mt-1">{deliveryProgress.toFixed(0)}% sent</p>
|
<p className="text-xs text-blue-700 mt-1">{deliveryProgress.toFixed(0)}% sent</p>
|
||||||
)}
|
)}
|
||||||
|
{campaign.status === 'DRAFT' && <p className="text-xs text-blue-600 mt-1">Estimated</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Open Rate */}
|
{/* Open Rate */}
|
||||||
@@ -273,6 +467,25 @@ export default function CampaignsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>Created {formatRelativeTime(campaign.createdAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(campaign.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>• Updated {formatRelativeTime(campaign.updatedAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(campaign.updatedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 pt-2 border-t border-neutral-100">
|
<div className="flex gap-2 pt-2 border-t border-neutral-100">
|
||||||
<Link href={`/campaigns/${campaign.id}`} className="flex-1">
|
<Link href={`/campaigns/${campaign.id}`} className="flex-1">
|
||||||
@@ -356,6 +569,18 @@ export default function CampaignsPage() {
|
|||||||
confirmText="Delete Campaign"
|
confirmText="Delete Campaign"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TemplateSelectionDialog
|
||||||
|
open={showTemplateDialog}
|
||||||
|
onOpenChange={setShowTemplateDialog}
|
||||||
|
onSelectTemplate={handleSelectTemplate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CampaignSelectionDialog
|
||||||
|
open={showCampaignDialog}
|
||||||
|
onOpenChange={setShowCampaignDialog}
|
||||||
|
onSelectCampaign={handleSelectCampaign}
|
||||||
|
/>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,20 +7,21 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Input,
|
Input,
|
||||||
Label,
|
Label
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import type {Contact} from '@plunk/db';
|
import type {Contact} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
||||||
import {ActivityFeed} from '../../components/ActivityFeed';
|
import {ActivityFeed} from '../../components/ActivityFeed';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {ArrowLeft, Calendar, Copy, Database, ExternalLink, Mail, Save, Settings, Trash2} from 'lucide-react';
|
import {ArrowLeft, Copy, Database, ExternalLink, Mail, Save, Settings, Trash2} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {ContactSchemas} from '@plunk/shared';
|
import {ContactSchemas} from '@plunk/shared';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
export default function ContactDetailPage() {
|
export default function ContactDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -250,34 +251,6 @@ export default function ContactDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-neutral-900">Created</p>
|
|
||||||
<p className="text-sm text-neutral-500">
|
|
||||||
{new Date(contact.createdAt).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
|
|
||||||
<p className="text-sm text-neutral-500">
|
|
||||||
{new Date(contact.updatedAt).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
|
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -285,6 +258,26 @@ export default function ContactDetailPage() {
|
|||||||
<p className="text-xs text-neutral-500 font-mono break-all">{contact.id}</p>
|
<p className="text-xs text-neutral-500 font-mono break-all">{contact.id}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">Created</p>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<p className="text-sm text-neutral-500">{dayjs(contact.createdAt).fromNow()}</p>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<p className="text-sm text-neutral-500">{dayjs(contact.updatedAt).fromNow()}</p>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(contact.updatedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
Input,
|
Input,
|
||||||
Label,
|
Label,
|
||||||
Switch,
|
Switch
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import type {Contact} from '@plunk/db';
|
import type {Contact} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -32,7 +33,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
XCircle,
|
XCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -40,6 +41,7 @@ import {useEffect, useRef, useState} from 'react';
|
|||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {ContactSchemas} from '@plunk/shared';
|
import {ContactSchemas} from '@plunk/shared';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface PaginatedContacts {
|
interface PaginatedContacts {
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
@@ -279,7 +281,12 @@ export default function ContactsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
|
||||||
{new Date(contact.createdAt).toLocaleDateString()}
|
<div className="group relative inline-block cursor-help">
|
||||||
|
{formatRelativeTime(contact.createdAt)}
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
@@ -324,9 +331,12 @@ export default function ContactsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-neutral-500">
|
<div className="group relative inline-block cursor-help">
|
||||||
{new Date(contact.createdAt).toLocaleDateString()}
|
<span className="text-xs text-neutral-500">{formatRelativeTime(contact.createdAt)}</span>
|
||||||
</span>
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(contact.createdAt).format('Do MMMM YYYY, h:mm A')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link href={`/contacts/${contact.id}`}>
|
<Link href={`/contacts/${contact.id}`}>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
|
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
@@ -15,14 +15,19 @@ import Link from 'next/link';
|
|||||||
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
|
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
|
||||||
import {DashboardLayout} from '../components/DashboardLayout';
|
import {DashboardLayout} from '../components/DashboardLayout';
|
||||||
import {QuickStart} from '../components/QuickStart';
|
import {QuickStart} from '../components/QuickStart';
|
||||||
|
import {SecurityWarningBanner} from '../components/SecurityWarningBanner';
|
||||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||||
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
|
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
|
||||||
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
||||||
|
import {useProjectSecurity} from '../lib/hooks/useProjectSecurity';
|
||||||
|
import {useConfig} from '../lib/hooks/useConfig';
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const {activeProject} = useActiveProject();
|
const {activeProject} = useActiveProject();
|
||||||
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
||||||
const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id);
|
const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id);
|
||||||
|
const {securityMetrics} = useProjectSecurity(activeProject?.id);
|
||||||
|
const {data: config} = useConfig();
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
@@ -58,22 +63,35 @@ export default function Index() {
|
|||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Project Disabled - Read-Only Mode</AlertTitle>
|
<AlertTitle>Project Disabled - Read-Only Mode</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
This project has been disabled due to security violations (high bounce or complaint rates). All scheduled
|
This project has been disabled due to security violations (high bounce or complaint rates). All
|
||||||
campaigns and workflows have been cancelled. The project is now in read-only mode - you can view your data
|
scheduled campaigns and workflows have been cancelled. The project is now in read-only mode - you can
|
||||||
but cannot create, update, or delete anything. Please contact support to resolve this issue.
|
view your data but cannot create, update, or delete anything. Please contact support to resolve this
|
||||||
|
issue.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Security Warning Banner */}
|
||||||
|
{activeProject && !activeProject.disabled && securityMetrics && (
|
||||||
|
<SecurityWarningBanner status={securityMetrics.status} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Subscription Warning Banner */}
|
{/* Subscription Warning Banner */}
|
||||||
{activeProject && !activeProject.disabled && !activeProject.subscription && (
|
{activeProject &&
|
||||||
|
!activeProject.disabled &&
|
||||||
|
!activeProject.subscription &&
|
||||||
|
config?.features.billing.enabled && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Upgrade to remove Plunk branding</AlertTitle>
|
<AlertTitle>Upgrade to remove Plunk branding</AlertTitle>
|
||||||
<AlertDescription className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<AlertDescription className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<span className="text-sm">Your emails currently include Plunk branding. Upgrade to a subscription to remove it.</span>
|
<span className="text-sm">
|
||||||
|
Your emails currently include Plunk branding. Upgrade to a subscription to remove it.
|
||||||
|
</span>
|
||||||
<Link href="/settings?tab=billing">
|
<Link href="/settings?tab=billing">
|
||||||
<Button size="sm" className="w-full sm:w-auto">Upgrade Now</Button>
|
<Button size="sm" className="w-full sm:w-auto">
|
||||||
|
Upgrade Now
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import type {Contact, Segment} from '@plunk/db';
|
import type {Contact, Segment} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {ArrowLeft, Calendar, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
|
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
@@ -21,6 +21,7 @@ import useSWR from 'swr';
|
|||||||
import type {FilterCondition} from '@plunk/types';
|
import type {FilterCondition} from '@plunk/types';
|
||||||
import {SegmentSchemas} from '@plunk/shared';
|
import {SegmentSchemas} from '@plunk/shared';
|
||||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface PaginatedContacts {
|
interface PaginatedContacts {
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
@@ -388,22 +389,6 @@ export default function SegmentDetailPage() {
|
|||||||
<CardTitle>Metadata</CardTitle>
|
<CardTitle>Metadata</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-neutral-900">Created</p>
|
|
||||||
<p className="text-sm text-neutral-500">{new Date(segment.createdAt).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
|
|
||||||
<p className="text-sm text-neutral-500">{new Date(segment.updatedAt).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
|
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -411,6 +396,26 @@ export default function SegmentDetailPage() {
|
|||||||
<p className="text-xs text-neutral-500 font-mono break-all">{segment.id}</p>
|
<p className="text-xs text-neutral-500 font-mono break-all">{segment.id}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">Created</p>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<p className="text-sm text-neutral-500">{dayjs(segment.createdAt).fromNow()}</p>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<p className="text-sm text-neutral-500">{dayjs(segment.updatedAt).fromNow()}</p>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import type {Segment} from '@plunk/db';
|
|||||||
import type {FilterCondition} from '@plunk/types';
|
import type {FilterCondition} from '@plunk/types';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {AlertTriangle, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
|
import {AlertTriangle, Calendar, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
// Helper function to count total filters in a condition
|
// Helper function to count total filters in a condition
|
||||||
function countFiltersInCondition(condition: unknown): number {
|
function countFiltersInCondition(condition: unknown): number {
|
||||||
@@ -194,8 +196,22 @@ export default function SegmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
<div className="text-xs text-neutral-500 pt-2 border-t border-neutral-200">
|
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
|
||||||
Created {new Date(segment.createdAt).toLocaleDateString()}
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>Created {formatRelativeTime(segment.createdAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>• Updated {formatRelativeTime(segment.updatedAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -37,7 +37,16 @@ import {
|
|||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {AlertTriangle, CreditCard, Database, Globe, Mail, Settings as SettingsIcon, Users} from 'lucide-react';
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CreditCard,
|
||||||
|
Database,
|
||||||
|
Globe,
|
||||||
|
Mail,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Shield,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
import type {z} from 'zod';
|
import type {z} from 'zod';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
@@ -50,14 +59,16 @@ import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
|
|||||||
import {SmtpSettings} from '../../components/SmtpSettings';
|
import {SmtpSettings} from '../../components/SmtpSettings';
|
||||||
import {DataManagementSettings} from '../../components/DataManagementSettings';
|
import {DataManagementSettings} from '../../components/DataManagementSettings';
|
||||||
import {TeamSettings} from '../../components/TeamSettings';
|
import {TeamSettings} from '../../components/TeamSettings';
|
||||||
|
import {SecuritySettings} from '../../components/SecuritySettings';
|
||||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {useProjects} from '../../lib/hooks/useProject';
|
import {useProjects} from '../../lib/hooks/useProject';
|
||||||
import {useConfig} from '../../lib/hooks/useConfig';
|
import {useConfig} from '../../lib/hooks/useConfig';
|
||||||
import {useUser} from '../../lib/hooks/useUser';
|
import {useUser} from '../../lib/hooks/useUser';
|
||||||
|
import {useProjectSecurity} from '../../lib/hooks/useProjectSecurity';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team';
|
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team' | 'security';
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
id: TabId;
|
id: TabId;
|
||||||
@@ -71,6 +82,7 @@ const buildTabs = (options: {billingEnabled: boolean; smtpEnabled: boolean}): Ta
|
|||||||
const allTabs: Tab[] = [
|
const allTabs: Tab[] = [
|
||||||
{id: 'general', label: 'General', icon: SettingsIcon},
|
{id: 'general', label: 'General', icon: SettingsIcon},
|
||||||
{id: 'team', label: 'Team', icon: Users},
|
{id: 'team', label: 'Team', icon: Users},
|
||||||
|
{id: 'security', label: 'Security', icon: Shield},
|
||||||
{id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled},
|
{id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled},
|
||||||
{id: 'domains', label: 'Domains', icon: Globe},
|
{id: 'domains', label: 'Domains', icon: Globe},
|
||||||
{id: 'smtp', label: 'SMTP', icon: Mail, condition: smtpEnabled},
|
{id: 'smtp', label: 'SMTP', icon: Mail, condition: smtpEnabled},
|
||||||
@@ -103,6 +115,8 @@ export default function Settings() {
|
|||||||
const currentUserMembership = membershipData?.data.find(m => m.userId === user?.id);
|
const currentUserMembership = membershipData?.data.find(m => m.userId === user?.id);
|
||||||
const currentUserRole = currentUserMembership?.role || 'MEMBER';
|
const currentUserRole = currentUserMembership?.role || 'MEMBER';
|
||||||
|
|
||||||
|
const {securityMetrics, isLoading: isLoadingSecurityMetrics} = useProjectSecurity(activeProject?.id);
|
||||||
|
|
||||||
const billingEnabled = config?.features.billing.enabled ?? false;
|
const billingEnabled = config?.features.billing.enabled ?? false;
|
||||||
const smtpEnabled = config?.features.smtp.enabled ?? false;
|
const smtpEnabled = config?.features.smtp.enabled ?? false;
|
||||||
const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false;
|
const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false;
|
||||||
@@ -368,9 +382,9 @@ export default function Settings() {
|
|||||||
{buildTabs({billingEnabled, smtpEnabled}).map(tab => {
|
{buildTabs({billingEnabled, smtpEnabled}).map(tab => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
return (
|
return (
|
||||||
<TabsTrigger key={tab.id} value={tab.id} className="flex items-center gap-2">
|
<TabsTrigger key={tab.id} value={tab.id} className="flex items-center gap-2" title={tab.label}>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{tab.label}
|
<span className="hidden md:inline">{tab.label}</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -647,7 +661,8 @@ export default function Settings() {
|
|||||||
<span className="font-medium">No Active Subscription</span>
|
<span className="font-medium">No Active Subscription</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600">
|
||||||
Start a subscription to unlock premium features and support the development of Plunk.
|
Start a subscription and support the development of Plunk. You will be charged a one-time
|
||||||
|
onboarding fee which will be credited to your first invoice.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -690,6 +705,24 @@ export default function Settings() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Security Tab */}
|
||||||
|
<TabsContent value="security">
|
||||||
|
{securityMetrics ? (
|
||||||
|
<SecuritySettings metrics={securityMetrics} isLoading={isLoadingSecurityMetrics} />
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Security Overview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
{isLoadingSecurityMetrics ? 'Loading...' : 'Unable to load security metrics'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Domains Tab */}
|
{/* Domains Tab */}
|
||||||
<TabsContent value="domains">
|
<TabsContent value="domains">
|
||||||
<DomainsSettings projectId={activeProject.id} />
|
<DomainsSettings projectId={activeProject.id} />
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ export default function TemplateEditorPage() {
|
|||||||
subject: editedTemplate.subject,
|
subject: editedTemplate.subject,
|
||||||
body: editedTemplate.body,
|
body: editedTemplate.body,
|
||||||
from: editedTemplate.from,
|
from: editedTemplate.from,
|
||||||
fromName: editedTemplate.fromName || undefined,
|
fromName: editedTemplate.fromName || null,
|
||||||
replyTo: editedTemplate.replyTo || undefined,
|
replyTo: editedTemplate.replyTo || null,
|
||||||
type: editedTemplate.type,
|
type: editedTemplate.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ export default function TemplateEditorPage() {
|
|||||||
|
|
||||||
{/* Email Body */}
|
{/* Email Body */}
|
||||||
<div>
|
<div>
|
||||||
<Card>
|
<Card className="overflow-visible">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Email Body</CardTitle>
|
<CardTitle>Email Body</CardTitle>
|
||||||
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
||||||
@@ -288,7 +288,6 @@ export default function TemplateEditorPage() {
|
|||||||
<EmailEditor
|
<EmailEditor
|
||||||
value={editedTemplate.body || ''}
|
value={editedTemplate.body || ''}
|
||||||
onChange={body => setEditedTemplate({...editedTemplate, body})}
|
onChange={body => setEditedTemplate({...editedTemplate, body})}
|
||||||
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ export default function CreateTemplatePage() {
|
|||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
from,
|
from,
|
||||||
fromName: fromName || undefined,
|
fromName: fromName || null,
|
||||||
replyTo: replyTo || undefined,
|
replyTo: replyTo || null,
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,17 +179,13 @@ export default function CreateTemplatePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Email Body */}
|
{/* Email Body */}
|
||||||
<Card>
|
<Card className="overflow-visible">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Email Body</CardTitle>
|
<CardTitle>Email Body</CardTitle>
|
||||||
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<EmailEditor
|
<EmailEditor value={body} onChange={setBody} />
|
||||||
value={body}
|
|
||||||
onChange={setBody}
|
|
||||||
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import {
|
|||||||
import type {Template} from '@plunk/db';
|
import type {Template} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
|
import {Calendar, Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface PaginatedTemplates {
|
interface PaginatedTemplates {
|
||||||
templates: Template[];
|
templates: Template[];
|
||||||
@@ -252,8 +254,22 @@ export default function TemplatesPage() {
|
|||||||
<p className="text-xs text-neutral-500 mb-1">From</p>
|
<p className="text-xs text-neutral-500 mb-1">From</p>
|
||||||
<p className="text-sm text-neutral-700">{template.from}</p>
|
<p className="text-sm text-neutral-700">{template.from}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6 text-sm text-neutral-500 pt-2 border-t border-neutral-100">
|
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
|
||||||
<div>Created {new Date(template.createdAt).toLocaleDateString()}</div>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>Created {formatRelativeTime(template.createdAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(template.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>• Updated {formatRelativeTime(template.updatedAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(template.updatedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{template.replyTo && (
|
{template.replyTo && (
|
||||||
<div>
|
<div>
|
||||||
Reply to: <span className="text-neutral-700">{template.replyTo}</span>
|
Reply to: <span className="text-neutral-700">{template.replyTo}</span>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import useSWR from 'swr';
|
|||||||
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
||||||
import {ReactFlowProvider} from '@xyflow/react';
|
import {ReactFlowProvider} from '@xyflow/react';
|
||||||
import {WorkflowSchemas} from '@plunk/shared';
|
import {WorkflowSchemas} from '@plunk/shared';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface WorkflowWithDetails extends Workflow {
|
interface WorkflowWithDetails extends Workflow {
|
||||||
steps: (WorkflowStep & {
|
steps: (WorkflowStep & {
|
||||||
@@ -611,7 +612,12 @@ export default function WorkflowEditorPage() {
|
|||||||
{execution.currentStep?.name ?? '-'}
|
{execution.currentStep?.name ?? '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
|
||||||
{new Date(execution.startedAt).toLocaleString()}
|
<div className="group relative inline-block cursor-help">
|
||||||
|
{dayjs(execution.startedAt).fromNow()}
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(execution.startedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
{(execution.status === 'RUNNING' || execution.status === 'WAITING') && (
|
{(execution.status === 'RUNNING' || execution.status === 'WAITING') && (
|
||||||
|
|||||||
@@ -23,13 +23,15 @@ import {
|
|||||||
import type {Workflow} from '@plunk/db';
|
import type {Workflow} from '@plunk/db';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
|
import {Calendar, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {WorkflowSchemas} from '@plunk/shared';
|
import {WorkflowSchemas} from '@plunk/shared';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface PaginatedWorkflows {
|
interface PaginatedWorkflows {
|
||||||
workflows: (Workflow & {_count?: {steps: number; executions: number}})[];
|
workflows: (Workflow & {_count?: {steps: number; executions: number}})[];
|
||||||
@@ -252,7 +254,23 @@ export default function WorkflowsPage() {
|
|||||||
<span className="font-medium text-neutral-900">{workflow._count?.executions ?? 0}</span>{' '}
|
<span className="font-medium text-neutral-900">{workflow._count?.executions ?? 0}</span>{' '}
|
||||||
executions
|
executions
|
||||||
</div>
|
</div>
|
||||||
<div>Created {new Date(workflow.createdAt).toLocaleDateString()}</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>Created {formatRelativeTime(workflow.createdAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(workflow.createdAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="group relative inline-block cursor-help">
|
||||||
|
<span>• Updated {formatRelativeTime(workflow.updatedAt)}</span>
|
||||||
|
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
|
||||||
|
{dayjs(workflow.updatedAt).format('DD MMMM YYYY, hh:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -21,10 +21,10 @@
|
|||||||
"fumadocs-openapi": "^10.0.11",
|
"fumadocs-openapi": "^10.0.11",
|
||||||
"fumadocs-ui": "16.0.8",
|
"fumadocs-ui": "16.0.8",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.7",
|
"next": "^16.0.10",
|
||||||
"next-sitemap": "^4.2.3",
|
"next-sitemap": "^4.2.3",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.3",
|
||||||
"shiki": "^3.15.0",
|
"shiki": "^3.15.0",
|
||||||
"unist-util-visit": "^5.0.0"
|
"unist-util-visit": "^5.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -183,6 +183,13 @@ services:
|
|||||||
# If using PEM files, SMTP_DOMAIN is optional
|
# If using PEM files, SMTP_DOMAIN is optional
|
||||||
SMTP_DOMAIN: ${SMTP_DOMAIN:-}
|
SMTP_DOMAIN: ${SMTP_DOMAIN:-}
|
||||||
|
|
||||||
|
# Platform emails
|
||||||
|
PLUNK_API_KEY: ${PLUNK_API_KEY:-}
|
||||||
|
PLUNK_FROM_ADDRESS: ${PLUNK_FROM_ADDRESS:-}
|
||||||
|
|
||||||
|
# Security
|
||||||
|
AUTO_PROJECT_DISABLE: ${AUTO_PROJECT_DISABLE:-false}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Persistent storage for application data
|
# Persistent storage for application data
|
||||||
- plunk_data:/app/data
|
- plunk_data:/app/data
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres
|
image: postgres:16-alpine
|
||||||
ports:
|
ports:
|
||||||
- 55432:5432
|
- 55432:5432
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "plunk",
|
"name": "plunk",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "events_projectId_name_createdAt_idx" ON "events"("projectId", "name", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "events_contactId_createdAt_idx" ON "events"("contactId", "createdAt");
|
||||||
@@ -611,6 +611,8 @@ model Event {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([projectId, contactId, name, createdAt]) // For event-based segment queries (fast!)
|
@@index([projectId, contactId, name, createdAt]) // For event-based segment queries (fast!)
|
||||||
@@index([contactId, name, createdAt]) // For per-contact event lookups
|
@@index([contactId, name, createdAt]) // For per-contact event lookups
|
||||||
|
@@index([projectId, name, createdAt]) // For event stats and analytics by type over time
|
||||||
|
@@index([contactId, createdAt]) // For contact activity timeline (recent events)
|
||||||
@@index([data(ops: JsonbOps)], type: Gin) // GIN index for event data queries
|
@@index([data(ops: JsonbOps)], type: Gin) // GIN index for event data queries
|
||||||
@@map("events")
|
@@map("events")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,13 @@
|
|||||||
"react-email": "5.0.1",
|
"react-email": "5.0.1",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^19.2.1",
|
|
||||||
"react-dom": "^19.2.1"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@plunk/db": "*",
|
"@plunk/db": "*",
|
||||||
"@plunk/types": "*",
|
"@plunk/types": "*",
|
||||||
"@react-email/components": "^1.0.0"
|
"@react-email/components": "^1.0.0",
|
||||||
|
"@react-email/tailwind": "^2.0.1",
|
||||||
|
"react": "^19.2.1",
|
||||||
|
"react-dom": "^19.2.1"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.js"
|
".": "./dist/index.js"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {Body, Container, Head, Html, Tailwind} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface EmailLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tailwindConfig = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
200: '#bfdbfe',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
900: '#1e3a8a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: [
|
||||||
|
'ui-sans-serif',
|
||||||
|
'system-ui',
|
||||||
|
'-apple-system',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'Segoe UI',
|
||||||
|
'Roboto',
|
||||||
|
'Helvetica Neue',
|
||||||
|
'Arial',
|
||||||
|
'sans-serif',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmailLayout({children}: EmailLayoutProps) {
|
||||||
|
return (
|
||||||
|
<Tailwind config={tailwindConfig}>
|
||||||
|
<Html>
|
||||||
|
<Head>
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<meta name="supported-color-schemes" content="light" />
|
||||||
|
</Head>
|
||||||
|
<Body className="m-0 bg-gray-50 p-0 font-sans antialiased">
|
||||||
|
<Container
|
||||||
|
className="mx-auto my-8 max-w-[600px] overflow-hidden rounded-lg bg-white shadow-sm"
|
||||||
|
style={{border: '1px solid #e5e7eb'}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
</Tailwind>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {Link, Section, Text} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
projectId?: string;
|
||||||
|
landingUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer({projectId, landingUrl = 'https://www.useplunk.com'}: FooterProps) {
|
||||||
|
return (
|
||||||
|
<Section className="border-t border-gray-100 bg-gray-50 px-8 py-8">
|
||||||
|
<Text className="mb-4 mt-0 text-center text-xs leading-relaxed text-gray-600">
|
||||||
|
This email was sent by Plunk. .
|
||||||
|
</Text>
|
||||||
|
{projectId && (
|
||||||
|
<Text className="mb-4 mt-0 text-center text-xs leading-relaxed text-gray-400">
|
||||||
|
Project ID: <span className="font-mono">{projectId}</span>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text className="mb-0 mt-0 text-center text-xs leading-relaxed text-gray-500">
|
||||||
|
<Link href={landingUrl} className="text-gray-500 no-underline hover:text-gray-700">
|
||||||
|
Plunk
|
||||||
|
</Link>
|
||||||
|
{' • '}
|
||||||
|
<Link
|
||||||
|
href={`${landingUrl}/privacy`}
|
||||||
|
className="text-gray-500 no-underline hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Privacy
|
||||||
|
</Link>
|
||||||
|
{' • '}
|
||||||
|
<Link
|
||||||
|
href={`${landingUrl}/terms`}
|
||||||
|
className="text-gray-500 no-underline hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Terms
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import {Img} from '@react-email/components';
|
import {Img, Section} from '@react-email/components';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
return (
|
return (
|
||||||
<>
|
<Section className="border-b border-gray-100 bg-white px-8 py-8">
|
||||||
<Img src="https://www.swyp.be/favicon/web-app-manifest-192x192.png" alt="Swyp Logo" width="40" height="40" />
|
<Img
|
||||||
</>
|
src="https://next.useplunk.com/assets/logo.png"
|
||||||
|
alt="Plunk"
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import {Heading, Link, Section, Text} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
import {EmailLayout} from '../common/EmailLayout';
|
||||||
|
import {Footer} from '../common/Footer';
|
||||||
|
import {Header} from '../common/Header';
|
||||||
|
|
||||||
|
interface BillingLimitExceededEmailProps {
|
||||||
|
projectName: string;
|
||||||
|
projectId: string;
|
||||||
|
usage: number;
|
||||||
|
limit: number;
|
||||||
|
sourceType: string;
|
||||||
|
dashboardUrl?: string;
|
||||||
|
landingUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BillingLimitExceededEmail({
|
||||||
|
projectName = 'My Project',
|
||||||
|
projectId = 'proj_example123',
|
||||||
|
usage = 10000,
|
||||||
|
limit = 10000,
|
||||||
|
sourceType = 'Transactional',
|
||||||
|
dashboardUrl = 'https://app.useplunk.com',
|
||||||
|
landingUrl = 'https://www.useplunk.com',
|
||||||
|
}: BillingLimitExceededEmailProps) {
|
||||||
|
return (
|
||||||
|
<EmailLayout>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<Section className="px-8 pb-10 pt-10">
|
||||||
|
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">
|
||||||
|
Email sending paused
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
|
||||||
|
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has reached your configured
|
||||||
|
monthly billing limit. Email sending has been paused to prevent charges beyond your set limit.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
|
||||||
|
<Section className="bg-gray-50 px-6 py-4">
|
||||||
|
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Usage this month
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="px-6 py-6">
|
||||||
|
<Section className="mb-6">
|
||||||
|
<Section className="mb-2 flex items-baseline justify-between">
|
||||||
|
<Text className="mb-0 mt-0 text-sm text-gray-600">Emails sent</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm font-medium text-gray-900">
|
||||||
|
{usage.toLocaleString()} / {limit.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="h-2 overflow-hidden rounded-full bg-gray-200">
|
||||||
|
<Section className="h-full bg-gray-900" style={{width: '100%'}} />
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
<Text className="mb-0 mt-0 text-xs text-gray-500">{sourceType} emails</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-8 rounded-lg bg-red-50 px-6 py-4" style={{border: '1px solid #fca5a5'}}>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-red-900">
|
||||||
|
All email sending is currently blocked. Your usage will reset at the start of next month, or you can
|
||||||
|
increase your billing limit to resume immediately.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">How to resume sending</Heading>
|
||||||
|
|
||||||
|
<Section className="mb-8">
|
||||||
|
<Section className="mb-3">
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Increase your billing limit</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Resume sending immediately by adjusting your monthly limit in billing settings
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-3">
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Wait for monthly reset</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Your usage will automatically reset at the start of next month
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Contact support</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Need immediate help? Our team is here to assist you
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={`${dashboardUrl}/settings?tab=billing`}
|
||||||
|
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
|
||||||
|
>
|
||||||
|
Adjust billing limit
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Link href={dashboardUrl} className="text-sm text-gray-500" style={{textDecoration: 'none'}}>
|
||||||
|
View project dashboard →
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Footer projectId={projectId} landingUrl={landingUrl} />
|
||||||
|
</EmailLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BillingLimitExceededEmail;
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {Heading, Link, Section, Text} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
import {EmailLayout} from '../common/EmailLayout';
|
||||||
|
import {Footer} from '../common/Footer';
|
||||||
|
import {Header} from '../common/Header';
|
||||||
|
|
||||||
|
interface BillingLimitWarningEmailProps {
|
||||||
|
projectName: string;
|
||||||
|
projectId: string;
|
||||||
|
usage: number;
|
||||||
|
limit: number;
|
||||||
|
percentage: number;
|
||||||
|
sourceType: string;
|
||||||
|
dashboardUrl?: string;
|
||||||
|
landingUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BillingLimitWarningEmail({
|
||||||
|
projectName = 'My Project',
|
||||||
|
projectId = 'proj_example123',
|
||||||
|
usage = 8500,
|
||||||
|
limit = 10000,
|
||||||
|
percentage = 85,
|
||||||
|
sourceType = 'Transactional',
|
||||||
|
dashboardUrl = 'https://app.useplunk.com',
|
||||||
|
landingUrl = 'https://www.useplunk.com',
|
||||||
|
}: BillingLimitWarningEmailProps) {
|
||||||
|
const percentageRounded = Math.round(percentage);
|
||||||
|
const remaining = limit - usage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmailLayout>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<Section className="px-8 pb-10 pt-10">
|
||||||
|
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">Usage limit warning</Heading>
|
||||||
|
|
||||||
|
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
|
||||||
|
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has used{' '}
|
||||||
|
<strong className="font-medium text-gray-900">{percentageRounded}%</strong> of your configured monthly billing
|
||||||
|
limit.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
|
||||||
|
<Section className="bg-gray-50 px-6 py-4">
|
||||||
|
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Usage this month
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="px-6 py-6">
|
||||||
|
<Section className="mb-6">
|
||||||
|
<Section className="mb-2 flex items-baseline justify-between">
|
||||||
|
<Text className="mb-0 mt-0 text-sm text-gray-600">Emails sent</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm font-medium text-gray-900">
|
||||||
|
{usage.toLocaleString()} / {limit.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="h-2 overflow-hidden rounded-full bg-gray-200">
|
||||||
|
<div className="h-full bg-gray-900" style={{width: `${percentageRounded}%`, height: '100%'}} />
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
<Text className="mb-0 mt-0 text-xs text-gray-500">{sourceType} emails</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-8 rounded-lg bg-amber-50 px-6 py-4" style={{border: '1px solid #fbbf24'}}>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-amber-900">
|
||||||
|
When you reach 100%, email sending will be paused to prevent charges beyond your configured limit. Your
|
||||||
|
usage will reset at the start of next month.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">Recommended actions</Heading>
|
||||||
|
|
||||||
|
<Section className="mb-8">
|
||||||
|
<Section className="mb-3">
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Increase your billing limit</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Adjust your monthly limit in billing settings to continue sending
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-3">
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Monitor your usage</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Track your sending patterns in the dashboard
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Optimize your sending</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Review and reduce email volume where possible
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={`${dashboardUrl}/settings?tab=billing`}
|
||||||
|
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
|
||||||
|
>
|
||||||
|
Adjust billing limit
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Link href={dashboardUrl} className="text-sm text-gray-500" style={{textDecoration: 'none'}}>
|
||||||
|
View project dashboard →
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Footer projectId={projectId} landingUrl={landingUrl} />
|
||||||
|
</EmailLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BillingLimitWarningEmail;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {Heading, Link, Section, Text} from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
import {EmailLayout} from '../common/EmailLayout';
|
||||||
|
import {Footer} from '../common/Footer';
|
||||||
|
import {Header} from '../common/Header';
|
||||||
|
|
||||||
|
interface ProjectDisabledEmailProps {
|
||||||
|
projectName: string;
|
||||||
|
projectId: string;
|
||||||
|
violations: string[];
|
||||||
|
dashboardUrl?: string;
|
||||||
|
landingUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectDisabledEmail({
|
||||||
|
projectName = 'My Project',
|
||||||
|
projectId = 'proj_example123',
|
||||||
|
violations = ['Bounce rate exceeded 10% threshold', 'Complaint rate exceeded 0.5% threshold'],
|
||||||
|
dashboardUrl = 'https://app.useplunk.com',
|
||||||
|
landingUrl = 'https://www.useplunk.com',
|
||||||
|
}: ProjectDisabledEmailProps) {
|
||||||
|
return (
|
||||||
|
<EmailLayout>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<Section className="px-8 pb-10 pt-10">
|
||||||
|
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">Project disabled</Heading>
|
||||||
|
|
||||||
|
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
|
||||||
|
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has been automatically
|
||||||
|
disabled to protect your sender reputation.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
|
||||||
|
<Section className="bg-gray-50 px-6 py-4">
|
||||||
|
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Issues detected
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="px-6 py-6">
|
||||||
|
{violations.map((violation, index) => (
|
||||||
|
<Section key={index} className="mb-3 last:mb-0">
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-700">{violation}</Text>
|
||||||
|
</Section>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-8 rounded-lg bg-red-50 px-6 py-4" style={{border: '1px solid #fca5a5'}}>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-red-900">
|
||||||
|
High bounce or complaint rates can severely damage your sender reputation and email deliverability. We've
|
||||||
|
disabled your project to prevent further issues.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">Steps to restore your project</Heading>
|
||||||
|
|
||||||
|
<Section className="mb-8">
|
||||||
|
<Section className="mb-3">
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Review your email lists</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Remove invalid or unengaged contacts
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-3">
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Verify recipient consent</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Ensure you have proper consent from all recipients
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Check email content</Text>
|
||||||
|
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
|
||||||
|
Verify your content follows best practices and isn't triggering spam filters
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={dashboardUrl}
|
||||||
|
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
|
||||||
|
>
|
||||||
|
View project dashboard
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Footer projectId={projectId} landingUrl={landingUrl} />
|
||||||
|
</EmailLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectDisabledEmail;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export {ProjectDisabledEmail} from './ProjectDisabled';
|
||||||
|
export {BillingLimitWarningEmail} from './BillingLimitWarning';
|
||||||
|
export {BillingLimitExceededEmail} from './BillingLimitExceeded';
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './send';
|
export * from './send';
|
||||||
|
export * from './notify';
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {render} from '@react-email/components';
|
||||||
|
import type {ReactElement} from 'react';
|
||||||
|
import {sendEmail} from './send';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if platform email notifications are enabled
|
||||||
|
* Requires PLUNK_API_KEY to be set in environment
|
||||||
|
*/
|
||||||
|
export function isPlatformEmailEnabled(): boolean {
|
||||||
|
return !!process.env.PLUNK_API_KEY && !!process.env.PLUNK_FROM_ADDRESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a platform notification email (only if PLUNK_API_KEY is configured)
|
||||||
|
* @param to - Recipient email address
|
||||||
|
* @param subject - Email subject line
|
||||||
|
* @param template - React email template component
|
||||||
|
*/
|
||||||
|
export async function sendPlatformEmail(to: string, subject: string, template: ReactElement): Promise<void> {
|
||||||
|
// Skip if platform emails are not enabled
|
||||||
|
if (!isPlatformEmailEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Render React email template to HTML
|
||||||
|
const html = await render(template);
|
||||||
|
|
||||||
|
// Send email using the platform
|
||||||
|
await sendEmail({
|
||||||
|
to,
|
||||||
|
from: process.env.PLUNK_FROM_ADDRESS as string,
|
||||||
|
subject,
|
||||||
|
body: html,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't throw - notifications should not break the main flow
|
||||||
|
console.error('[Platform Email] Failed to send notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
interface SendEmailParams {
|
interface SendEmailParams {
|
||||||
to: string;
|
to: string;
|
||||||
|
from: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendEmail({to, subject, body}: SendEmailParams) {
|
export async function sendEmail({to, from, subject, body}: SendEmailParams) {
|
||||||
const apiUrl = process.env.API_URI ?? 'http://localhost:8080';
|
const apiUrl = process.env.API_URI;
|
||||||
const res = await fetch(`${apiUrl}/v1/send`, {
|
const res = await fetch(`${apiUrl}/v1/send`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -14,6 +15,7 @@ export async function sendEmail({to, subject, body}: SendEmailParams) {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
to,
|
to,
|
||||||
|
from,
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ export const TemplateSchemas = {
|
|||||||
subject: z.string().min(1),
|
subject: z.string().min(1),
|
||||||
body: z.string().min(1),
|
body: z.string().min(1),
|
||||||
from: email,
|
from: email,
|
||||||
fromName: z.string().max(100).optional(),
|
fromName: z.string().max(100).nullish(),
|
||||||
replyTo: email.optional(),
|
replyTo: email.nullish(),
|
||||||
type: z.nativeEnum(TemplateType).default('MARKETING'),
|
type: z.nativeEnum(TemplateType).default('MARKETING'),
|
||||||
}),
|
}),
|
||||||
update: z.object({
|
update: z.object({
|
||||||
@@ -152,8 +152,8 @@ export const TemplateSchemas = {
|
|||||||
subject: z.string().min(1).optional(),
|
subject: z.string().min(1).optional(),
|
||||||
body: z.string().min(1).optional(),
|
body: z.string().min(1).optional(),
|
||||||
from: email.optional(),
|
from: email.optional(),
|
||||||
fromName: z.string().max(100).optional(),
|
fromName: z.string().max(100).nullish(),
|
||||||
replyTo: email.optional(),
|
replyTo: email.nullish(),
|
||||||
type: z.nativeEnum(TemplateType).optional(),
|
type: z.nativeEnum(TemplateType).optional(),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -282,8 +282,8 @@ export const CampaignSchemas = {
|
|||||||
subject: z.string().min(1),
|
subject: z.string().min(1),
|
||||||
body: z.string().min(1),
|
body: z.string().min(1),
|
||||||
from: email,
|
from: email,
|
||||||
fromName: z.string().max(100).optional(),
|
fromName: z.string().max(100).nullish(),
|
||||||
replyTo: email.optional(),
|
replyTo: email.nullish(),
|
||||||
audienceType: z.nativeEnum(CampaignAudienceType),
|
audienceType: z.nativeEnum(CampaignAudienceType),
|
||||||
audienceCondition: filterConditionSchema.optional(),
|
audienceCondition: filterConditionSchema.optional(),
|
||||||
segmentId: uuid.optional(),
|
segmentId: uuid.optional(),
|
||||||
@@ -297,8 +297,8 @@ export const CampaignSchemas = {
|
|||||||
subject: z.string().optional(),
|
subject: z.string().optional(),
|
||||||
body: z.string().optional(),
|
body: z.string().optional(),
|
||||||
from: z.string().optional(),
|
from: z.string().optional(),
|
||||||
fromName: z.string().max(100).optional(),
|
fromName: z.string().max(100).nullish(),
|
||||||
replyTo: z.string().optional(),
|
replyTo: z.string().nullish(),
|
||||||
audienceType: z.nativeEnum(CampaignAudienceType).optional(),
|
audienceType: z.nativeEnum(CampaignAudienceType).optional(),
|
||||||
audienceCondition: filterConditionSchema.optional(),
|
audienceCondition: filterConditionSchema.optional(),
|
||||||
segmentId: z.string().optional(),
|
segmentId: z.string().optional(),
|
||||||
@@ -340,16 +340,14 @@ export const ActionSchemas = {
|
|||||||
template: uuid.optional(),
|
template: uuid.optional(),
|
||||||
subscribed: z.boolean().optional().default(false),
|
subscribed: z.boolean().optional().default(false),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
from: z
|
from: z.union([
|
||||||
.union([
|
|
||||||
email, // Simple email string (backward compatible)
|
email, // Simple email string (backward compatible)
|
||||||
z.object({
|
z.object({
|
||||||
// Object with name and email
|
// Object with name and email
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
email: email,
|
email: email,
|
||||||
}),
|
}),
|
||||||
])
|
]),
|
||||||
.optional(),
|
|
||||||
reply: email.optional(),
|
reply: email.optional(),
|
||||||
headers: z.record(z.string().max(998)).optional(),
|
headers: z.record(z.string().max(998)).optional(),
|
||||||
data: jsonSchema.optional(),
|
data: jsonSchema.optional(),
|
||||||
|
|||||||
@@ -55,3 +55,40 @@ export interface SegmentMembershipComputeResult {
|
|||||||
removed: number;
|
removed: number;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security status types
|
||||||
|
export interface SecurityRateData {
|
||||||
|
total: number;
|
||||||
|
bounces: number;
|
||||||
|
complaints: number;
|
||||||
|
bounceRate: number;
|
||||||
|
complaintRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityStatus {
|
||||||
|
projectId: string;
|
||||||
|
isHealthy: boolean;
|
||||||
|
shouldDisable: boolean;
|
||||||
|
sevenDay: SecurityRateData;
|
||||||
|
allTime: SecurityRateData;
|
||||||
|
violations: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityThresholds {
|
||||||
|
MIN_EMAILS_FOR_ENFORCEMENT: number;
|
||||||
|
BOUNCE_7DAY_WARNING: number;
|
||||||
|
BOUNCE_7DAY_CRITICAL: number;
|
||||||
|
BOUNCE_ALLTIME_WARNING: number;
|
||||||
|
BOUNCE_ALLTIME_CRITICAL: number;
|
||||||
|
COMPLAINT_7DAY_WARNING: number;
|
||||||
|
COMPLAINT_7DAY_CRITICAL: number;
|
||||||
|
COMPLAINT_ALLTIME_WARNING: number;
|
||||||
|
COMPLAINT_ALLTIME_CRITICAL: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSecurityMetrics {
|
||||||
|
status: SecurityStatus;
|
||||||
|
thresholds: SecurityThresholds;
|
||||||
|
isDisabled: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.1"
|
"react-dom": "^19.2.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react-day-picker": "^8.10.0",
|
"react-day-picker": "^8.10.0",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
|
"recharts": "^3.5.1",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-10 items-center justify-center rounded-md bg-neutral-100 p-1 text-neutral-500',
|
'inline-flex h-10 items-center justify-start rounded-md bg-neutral-100 p-1 text-neutral-500',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
+9
-3
@@ -65,7 +65,9 @@
|
|||||||
"NEXT_PUBLIC_API_URI",
|
"NEXT_PUBLIC_API_URI",
|
||||||
"NEXT_PUBLIC_LANDING_URI",
|
"NEXT_PUBLIC_LANDING_URI",
|
||||||
"NEXT_PUBLIC_DASHBOARD_URI",
|
"NEXT_PUBLIC_DASHBOARD_URI",
|
||||||
"NEXT_PUBLIC_WIKI_URI"
|
"NEXT_PUBLIC_WIKI_URI",
|
||||||
|
"PLUNK_API_KEY",
|
||||||
|
"PLUNK_FROM_ADDRESS"
|
||||||
],
|
],
|
||||||
"inputs": ["$TURBO_DEFAULT$", ".env*"]
|
"inputs": ["$TURBO_DEFAULT$", ".env*"]
|
||||||
},
|
},
|
||||||
@@ -113,7 +115,9 @@
|
|||||||
"NEXT_PUBLIC_API_URI",
|
"NEXT_PUBLIC_API_URI",
|
||||||
"NEXT_PUBLIC_LANDING_URI",
|
"NEXT_PUBLIC_LANDING_URI",
|
||||||
"NEXT_PUBLIC_DASHBOARD_URI",
|
"NEXT_PUBLIC_DASHBOARD_URI",
|
||||||
"NEXT_PUBLIC_WIKI_URI"
|
"NEXT_PUBLIC_WIKI_URI",
|
||||||
|
"PLUNK_API_KEY",
|
||||||
|
"PLUNK_FROM_ADDRESS"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dev:worker": {
|
"dev:worker": {
|
||||||
@@ -152,7 +156,9 @@
|
|||||||
"NEXT_PUBLIC_API_URI",
|
"NEXT_PUBLIC_API_URI",
|
||||||
"NEXT_PUBLIC_LANDING_URI",
|
"NEXT_PUBLIC_LANDING_URI",
|
||||||
"NEXT_PUBLIC_DASHBOARD_URI",
|
"NEXT_PUBLIC_DASHBOARD_URI",
|
||||||
"NEXT_PUBLIC_WIKI_URI"
|
"NEXT_PUBLIC_WIKI_URI",
|
||||||
|
"PLUNK_API_KEY",
|
||||||
|
"PLUNK_FROM_ADDRESS"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"clean": {
|
"clean": {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default defineConfig({
|
|||||||
'@plunk/db': path.resolve(__dirname, './packages/db/src'),
|
'@plunk/db': path.resolve(__dirname, './packages/db/src'),
|
||||||
'@plunk/shared': path.resolve(__dirname, './packages/shared/src'),
|
'@plunk/shared': path.resolve(__dirname, './packages/shared/src'),
|
||||||
'@plunk/types': path.resolve(__dirname, './packages/types/src'),
|
'@plunk/types': path.resolve(__dirname, './packages/types/src'),
|
||||||
|
'@plunk/email': path.resolve(__dirname, './packages/email/src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user