Compare commits

..
53 Commits
Author SHA1 Message Date
Dries Augustyns 409d1b83eb Merge pull request #231 from useplunk/release-please--branches--next--components--plunk 2025-12-16 16:24:37 +01:00
github-actions[bot] c3bd5d1d5f chore(next): release 0.2.0 2025-12-16 12:51:03 +00:00
Dries Augustyns 3336fa1e38 fix: Overflow of inputs in email editor 2025-12-16 13:50:32 +01:00
Dries Augustyns 769e3748a0 fix: Increase z-index of color picker 2025-12-16 13:19:52 +01:00
Dries Augustyns 91eb0f3a69 fix: Better highlight warnings in SecurityWarningBanner.tsx 2025-12-16 12:00:33 +01:00
Dries Augustyns 863e784e1c fix: Add additional checks for disabled projects 2025-12-16 11:52:53 +01:00
Dries Augustyns 780741e37f fix: Add additional checks for disabled projects 2025-12-15 07:41:21 +01:00
Dries Augustyns e50c33ae4b fix: Clear notification cache keys when changing billing limits 2025-12-13 10:28:11 +01:00
Dries Augustyns f34052ed73 fix: Prevent manual tracking of internal events that are automatically tracked 2025-12-12 20:30:49 +01:00
Dries Augustyns a555a12736 fix: Move react and react-dom to dependencies instead of peerDependency for API 2025-12-12 13:00:11 +01:00
Dries Augustyns f37bfccbc2 fix: Move react and react-dom to dependencies instead of peerDependency for API 2025-12-12 13:00:02 +01:00
Dries Augustyns db1496e525 chore: remove module config 2025-12-12 12:44:04 +01:00
Dries Augustyns 443c6271f1 chore: Add email package to vitest config 2025-12-12 12:38:58 +01:00
Dries Augustyns 9cf5222c8c chore: Update turbo.json and example docker-compose to include Plunk .env variables 2025-12-12 12:31:30 +01:00
Dries Augustyns 2485d2ff1d feat: Added platform emails for billing limits and disabled projects 2025-12-12 12:28:53 +01:00
Dries Augustyns cb40669385 chore: Remove accidental package-lock.json 2025-12-12 08:16:25 +01:00
Dries Augustyns d9e7f9f5cf Merge pull request #238 from Nithur-M/next 2025-12-12 08:14:25 +01:00
Dries Augustyns dafa90f0dd chore: Also updated react and react-dom versions 2025-12-12 08:05:10 +01:00
nithur-m 9737c2b67d update to next 16.0.10 2025-12-12 09:04:57 +05:30
Dries Augustyns 1cd89d1375 fix: add additional indexes on event model 2025-12-11 19:09:54 +01:00
Dries Augustyns 11ef47b576 fix: Unauthenticated users are redirected to login on subscribe/unsubscribe/manage pages 2025-12-11 18:41:37 +01:00
Dries Augustyns fbd303801f feat: Add security center and warning for exceeding bounce/complaint rates 2025-12-11 18:34:30 +01:00
Dries Augustyns 0ecd82d62e Merge pull request #234 from Nithur-M/next 2025-12-11 18:24:12 +01:00
nithur-m 1452cbd591 format fulldate for campaign sent time 2025-12-11 20:03:01 +05:30
Dries Augustyns 07f875c18c fix: display email progress instead of scheduling progress for campaigns 2025-12-11 14:36:26 +01:00
Dries Augustyns 5e4adb71ad fix: custom relative time to shorten strings for better UI fit 2025-12-11 11:13:39 +01:00
Dries Augustyns 1019ba0d82 feat: Improved createdAt and updatedAt visualisation 2025-12-11 10:57:56 +01:00
Dries Augustyns 6b25bbe2f8 feat: ability to create new campaigns based on templates or previous campaigns 2025-12-10 21:04:51 +01:00
Dries Augustyns b9758149f5 chore: update description in subscription card 2025-12-10 20:30:27 +01:00
Dries Augustyns d438abf1a5 Merge branch 'next' of https://github.com/useplunk/plunk into next 2025-12-10 19:53:26 +01:00
Dries Augustyns 9375c598ee chore: add indicator about campaign recipients recalculation 2025-12-10 19:52:09 +01:00
Dries Augustyns 8f636cc5b9 Merge pull request #232 from Nithur-M/next 2025-12-10 16:07:03 +01:00
Dries Augustyns 1430f31e2d chore: replace standard logging with signale 2025-12-10 14:21:42 +01:00
Dries Augustyns 3225c5005b feat: Automatically detect rate limit from AWS with ability to override in .env 2025-12-10 14:03:30 +01:00
nithur-m f36b213f0a show text left on projects dropdown 2025-12-10 17:03:49 +05:30
Dries Augustyns cb15fad7da test: properly mock Stripe in CampaignService.test.ts 2025-12-09 19:05:22 +01:00
Dries Augustyns a3bbb1ed64 fix: prevent scheduling of campaign if billing limit reached 2025-12-09 18:50:05 +01:00
Dries Augustyns 04275e3cf5 fix: show correct default value for placeholder 2025-12-09 15:57:54 +01:00
Dries Augustyns badb035585 fix: ability to clear reply-to and from name from templates and campaigns 2025-12-09 15:47:27 +01:00
Dries Augustyns c12d0c0898 test: Correctly mock STRIPE_ENABLED 2025-12-09 15:23:48 +01:00
Dries Augustyns 6e9f11f6a7 test: Correctly mock STRIPE_ENABLED 2025-12-09 14:52:40 +01:00
Dries Augustyns 1713b2398d fix: Only check free tier limits if billing is enabled 2025-12-09 14:26:59 +01:00
Dries Augustyns 68f99798f8 fix: Verify if sending without tracking is possible in SESService 2025-12-09 14:10:47 +01:00
Dries Augustyns 5a0a41c6bb fix: Only fetch project members if project Id is defined 2025-12-09 13:24:54 +01:00
Dries Augustyns 129a496206 chore: linting error 2025-12-09 11:24:13 +01:00
Dries Augustyns d0191be31d fix: Correctly show recipient count during creation and edit 2025-12-09 11:08:53 +01:00
Dries Augustyns 85b9d7afe7 fix: Allow changing audience type after creation of campaign 2025-12-09 10:40:42 +01:00
Dries Augustyns ff5c79cfcf fix: Update recipient count on create/update of campaign 2025-12-09 10:27:56 +01:00
Dries Augustyns 672f1e6657 feat: Add improved html editor using CodeMirror 2025-12-09 10:15:33 +01:00
Dries Augustyns 433e796c04 fix: Hide upsell banner if billing is not configured 2025-12-09 08:45:53 +01:00
Dries Augustyns 77089fcdd6 style: Hide labels of tabs on smaller screens 2025-12-08 21:48:09 +01:00
Dries Augustyns a372e55b78 style: Added placeholder to EmailEditor.tsx 2025-12-08 17:45:08 +01:00
Dries Augustyns 8ec0f50f68 chore: Set version of local database to postgres-16 to align with production template 2025-12-08 17:40:24 +01:00
94 changed files with 6245 additions and 2958 deletions
+19
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "0.1.1" ".": "0.2.0"
} }
+39
View File
@@ -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)
+2
View File
@@ -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:**
+14
View File
@@ -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
View File
@@ -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 => ({
+16
View File
@@ -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';
+30 -8
View File
@@ -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,
+9 -9
View File
@@ -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);
+7 -6
View File
@@ -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',
}); });
+3 -2
View File
@@ -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',
}); });
+37 -4
View File
@@ -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) {
+2 -1
View File
@@ -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',
}); });
+59 -17
View File
@@ -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 {
+2 -1
View File
@@ -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',
}); });
+6 -5
View File
@@ -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;
+93 -10
View File
@@ -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;
+8 -7
View File
@@ -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;
+9 -8
View File
@@ -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;
+1 -1
View File
@@ -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;
+6 -4
View File
@@ -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);
} }
} }
+4 -3
View File
@@ -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);
+210 -5
View File
@@ -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);
}
}
} }
+63 -44
View File
@@ -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,
);
} }
} }
+1 -1
View File
@@ -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({
+46 -13
View File
@@ -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);
} }
} }
+7 -5
View File
@@ -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
} }
} }
+32 -2
View File
@@ -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;
}
};
+95 -12
View File
@@ -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);
} }
+5 -4
View File
@@ -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}`,
); );
+4 -4
View File
@@ -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> {
+2 -1
View File
@@ -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,
+37
View File
@@ -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;
+2 -2
View File
@@ -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;
} }
+3 -3
View File
@@ -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": {
+9 -3
View File
@@ -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",
+10 -16
View File
@@ -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&apos;re on the free tier with 1,000 emails per month. Upgrade to a paid subscription for You&apos;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&apos;ve used {Math.round(usage.percentage)}% of your{' '} <strong>Warning:</strong> You&apos;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>
);
}
+1 -1
View File
@@ -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"
+7 -2
View File
@@ -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&apos;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>
);
}
+20
View File
@@ -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,
};
}
+14 -3
View File
@@ -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 (
<> <>
+71 -44
View File
@@ -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>
+127 -9
View File
@@ -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&#39;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 */}
+230 -5
View File
@@ -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>
</> </>
); );
+23 -30
View File
@@ -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>
+16 -6
View File
@@ -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">
+25 -7
View File
@@ -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>
+22 -17
View File
@@ -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>
+19 -3
View File
@@ -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>
+38 -5
View File
@@ -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} />
+3 -4
View File
@@ -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>
+4 -8
View File
@@ -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>
+19 -3
View File
@@ -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>
+7 -1
View File
@@ -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') && (
+20 -2
View File
@@ -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>
+3 -3
View File
@@ -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"
}, },
+7
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
services: services:
db: db:
image: postgres image: postgres:16-alpine
ports: ports:
- 55432:5432 - 55432:5432
environment: environment:
+1 -1
View File
@@ -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",
@@ -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");
+2
View File
@@ -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")
} }
+4 -5
View File
@@ -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"
+58
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+10 -4
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
export {ProjectDisabledEmail} from './ProjectDisabled';
export {BillingLimitWarningEmail} from './BillingLimitWarning';
export {BillingLimitExceededEmail} from './BillingLimitExceeded';
+1
View File
@@ -1 +1,2 @@
export * from './send'; export * from './send';
export * from './notify';
+40
View File
@@ -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);
}
}
+4 -2
View File
@@ -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,
}), }),
+10 -12
View File
@@ -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(),
+37
View File
@@ -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;
}
+3 -2
View File
@@ -15,8 +15,8 @@
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.2.1", "react": "^19.2.3",
"react-dom": "^19.2.1" "react-dom": "^19.2.3"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -57,6 +57,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react-day-picker": "^8.10.0", "react-day-picker": "^8.10.0",
"react-hook-form": "^7.66.0", "react-hook-form": "^7.66.0",
"recharts": "^3.5.1",
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
+1 -1
View File
@@ -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
View File
@@ -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": {
+1
View File
@@ -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'),
}, },
}, },
}); });
+2056 -2434
View File
File diff suppressed because it is too large Load Diff