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)
# 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)
# ========================================
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.1.1"
".": "0.2.0"
}
+39
View File
@@ -1,5 +1,44 @@
# 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)
+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_METER_EVENT_NAME`
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
**Important Notes:**
+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_NO_TRACKING=plunk-configuration-set-no-tracking # Optional: without tracking (enables toggle in UI)
# Email sending rate limit (emails per second)
# If not set, automatically fetches from AWS SES account quota (recommended)
# Set this to override AWS quota (useful for setting lower limits or testing)
# Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts)
# EMAIL_RATE_LIMIT_PER_SECOND=14
# ==============================================================================
# OAuth (Optional - for social login)
# ==============================================================================
@@ -70,3 +76,11 @@ STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ONBOARDING= # Optional: One-time onboarding fee price ID (e.g., price_xxxxx)
STRIPE_PRICE_EMAIL_USAGE= # Required: Metered price ID for pay-per-email billing
STRIPE_METER_EVENT_NAME=emails # Meter event name (API key from your Stripe meter, default: emails)
# ==============================================================================
# Security (Optional)
# ==============================================================================
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
# Set to 'false' to disable automatic project suspension (useful for self-hosters who manage manually)
# Default: true (automatic project disabling enabled)
# AUTO_PROJECT_DISABLE=true
@@ -80,6 +80,7 @@ describe('Actions API Integration Tests', () => {
it('should validate subject and body required when no template', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
from: '[email protected]',
// Missing subject, body, and template
});
@@ -169,6 +170,7 @@ describe('Actions API Integration Tests', () => {
it('should accept to as string (backward compatible)', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
from: '[email protected]',
subject: 'Test',
body: 'Test',
});
@@ -182,6 +184,7 @@ describe('Actions API Integration Tests', () => {
name: 'Jane Doe',
email: '[email protected]',
},
from: '[email protected]',
subject: 'Test',
body: 'Test',
});
@@ -194,6 +197,7 @@ describe('Actions API Integration Tests', () => {
to: {
email: '[email protected]',
},
from: '[email protected]',
subject: 'Test',
body: 'Test',
});
@@ -204,6 +208,7 @@ describe('Actions API Integration Tests', () => {
it('should accept to as array of strings', () => {
const result = ActionSchemas.send.safeParse({
to: ['[email protected]', '[email protected]'],
from: '[email protected]',
subject: 'Test',
body: 'Test',
});
@@ -217,6 +222,7 @@ describe('Actions API Integration Tests', () => {
{name: 'Jane Doe', email: '[email protected]'},
{name: 'John Smith', email: '[email protected]'},
],
from: '[email protected]',
subject: 'Test',
body: 'Test',
});
@@ -227,6 +233,7 @@ describe('Actions API Integration Tests', () => {
it('should accept to as mixed array of strings and objects', () => {
const result = ActionSchemas.send.safeParse({
to: ['[email protected]', {name: 'John Smith', email: '[email protected]'}],
from: '[email protected]',
subject: 'Test',
body: 'Test',
});
@@ -442,4 +449,288 @@ describe('Actions API Integration Tests', () => {
}
});
});
// ========================================
// RESERVED EVENT VALIDATION
// ========================================
describe('Reserved Event Validation', () => {
describe('Email events (email.*)', () => {
it('should reject email.sent event', () => {
const result = ActionSchemas.track.safeParse({
event: 'email.sent',
email: '[email protected]',
});
// Schema allows it, but controller validation should reject
expect(result.success).toBe(true);
// Verify the error would be thrown by controller
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.sent" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
received: 'email.sent',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR);
expect(error.errors[0]?.code).toBe('reserved_event');
});
it('should reject email.delivery event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.delivery" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
received: 'email.delivery',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
expect(error.errors[0]?.field).toBe('event');
});
it('should reject email.open event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.open" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
it('should reject email.click event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.click" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
it('should reject email.bounce event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.bounce" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
it('should reject email.complaint event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.complaint" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
it('should reject any email.* pattern', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.custom" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
});
describe('Contact events', () => {
it('should reject contact.subscribed event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "contact.subscribed" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
expect(error.errors[0]?.field).toBe('event');
});
it('should reject contact.unsubscribed event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "contact.unsubscribed" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
it('should allow other contact.* events', () => {
const result1 = ActionSchemas.track.safeParse({
event: 'contact.created',
email: '[email protected]',
});
const result2 = ActionSchemas.track.safeParse({
event: 'contact.updated',
email: '[email protected]',
});
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
});
});
describe('Segment events', () => {
it('should reject segment.*.entry events', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "segment.vip-users.entry" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
expect(error.errors[0]?.code).toBe('reserved_event');
});
it('should reject segment.*.exit events', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "segment.premium.exit" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
it('should allow other segment.* events', () => {
const result1 = ActionSchemas.track.safeParse({
event: 'segment.created',
email: '[email protected]',
});
const result2 = ActionSchemas.track.safeParse({
event: 'segment.premium.updated',
email: '[email protected]',
});
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
});
});
describe('Custom user events', () => {
it('should allow custom user events', () => {
const testCases = [
'user.signup',
'purchase.completed',
'order.placed',
'custom.event',
'product.viewed',
'cart.abandoned',
];
for (const eventName of testCases) {
const result = ActionSchemas.track.safeParse({
event: eventName,
email: '[email protected]',
});
expect(result.success).toBe(true);
}
});
it('should allow events with similar but different prefixes', () => {
const testCases = ['emails.sent', 'contacts.subscribed', 'segments.entry'];
for (const eventName of testCases) {
const result = ActionSchemas.track.safeParse({
event: eventName,
email: '[email protected]',
});
expect(result.success).toBe(true);
}
});
});
describe('Error structure for reserved events', () => {
it('should return ValidationError with correct structure', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.sent" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
received: 'email.sent',
},
],
'Cannot track reserved system event',
);
expect(error).toBeInstanceOf(ValidationError);
expect(error.code).toBe(422);
expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR);
expect(error.message).toBe('Cannot track reserved system event');
expect(error.errors).toHaveLength(1);
expect(error.errors[0]).toMatchObject({
field: 'event',
code: 'reserved_event',
received: 'email.sent',
});
});
});
});
});
+8 -2
View File
@@ -14,12 +14,13 @@ import {
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
NODE_ENV,
PLUNK_ENABLED,
PORT,
S3_ENABLED,
SMTP_ENABLED,
STRIPE_ENABLED,
TRACKING_TOGGLE_ENABLED,
WIKI_URI,
WIKI_URI
} from './app/constants.js';
import {Actions} from './controllers/Actions.js';
import {Activity} from './controllers/Activity.js';
@@ -309,7 +310,7 @@ server.app.use((error: Error, req: Request, res: Response, _next: NextFunction)
// Global error handlers to prevent server crashes
process.on('unhandledRejection', (reason, promise) => {
signale.error('Unhandled Promise Rejection:', reason);
console.error('Promise:', promise);
signale.error('Promise:', promise);
// Don't exit the process - just log the error
});
@@ -351,6 +352,11 @@ void prisma.$connect().then(async () => {
? 'Per-project tracking toggle enabled'
: 'Always tracking or always no-tracking',
},
{
name: 'Platform emails',
enabled: PLUNK_ENABLED,
details: PLUNK_ENABLED ? 'Platform email notifications enabled' : 'PLUNK_API_KEY not configured',
},
];
const rows = features.map(f => ({
+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_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
// Email Processing Rate Limit (optional override)
// If not set, will automatically fetch from AWS SES account quota
// Set this to override AWS quota (useful for setting lower limits or testing)
export const EMAIL_RATE_LIMIT_PER_SECOND = process.env.EMAIL_RATE_LIMIT_PER_SECOND
? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND)
: undefined;
// Storage
export const REDIS_URL = validateEnv('REDIS_URL');
export const DATABASE_URL = validateEnv('DATABASE_URL');
@@ -87,3 +94,12 @@ export const SMTP_PORT_SUBMISSION = Number(validateEnv('PORT_SUBMISSION', '587')
// Enable SMTP features only when explicitly enabled via env or when a non-default domain is configured
export const SMTP_ENABLED =
process.env.SMTP_ENABLED === 'true' || (SMTP_DOMAIN !== 'localhost' && NODE_ENV !== 'development');
export const PLUNK_API_KEY = validateEnv('PLUNK_API_KEY', '');
export const PLUNK_FROM_ADDRESS = validateEnv('PLUNK_FROM_ADDRESS', '');
export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
// Security (optional)
// Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
// Useful for self-hosters who want to manage project status manually
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
+30 -8
View File
@@ -9,7 +9,7 @@ import {ContactService} from '../services/ContactService.js';
import {DomainService} from '../services/DomainService.js';
import {EmailService} from '../services/EmailService.js';
import {EventService} from '../services/EventService.js';
import {NotFound} from '../exceptions/index.js';
import {NotFound, ValidationError} from '../exceptions/index.js';
import {CatchAsync} from '../utils/asyncHandler.js';
/**
@@ -55,6 +55,21 @@ export class Actions {
// Zod validation - errors automatically handled by global error handler
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
// Prevent manual tracking of reserved system events
if (EventService.isReservedEvent(event)) {
throw new ValidationError(
[
{
field: 'event',
message: `Event name "${event}" is reserved for system use and cannot be manually tracked`,
code: 'reserved_event',
received: event,
},
],
'Cannot track reserved system event',
);
}
// Create or update contact with persistent data only
// ContactService.upsert will filter out non-persistent fields
const contact = await ContactService.upsert(
@@ -223,14 +238,21 @@ export class Actions {
templateId = templateRecord.id;
}
// Verify 'from' domain is verified if provided
const senderEmail = emailFrom || '[email protected]'; // Default sender
// Only verify custom domains (not the default [email protected])
if (emailFrom && emailFrom !== '[email protected]') {
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
if (!emailFrom) {
throw new ValidationError(
[
{
field: 'from',
message: 'Sender email is required either in request or template',
code: 'required',
},
],
'Could not parse sender email',
);
}
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
const replyToEmail = emailReplyTo;
const timestamp = new Date();
@@ -281,7 +303,7 @@ export class Actions {
contactId: contact.id,
subject: renderedSubject,
body: renderedBody,
from: senderEmail,
from: emailFrom,
fromName: emailFromName,
toName: recipient.name,
replyTo: replyToEmail,
+9 -9
View File
@@ -1,6 +1,6 @@
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignSchemas} from '@plunk/shared';
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {HttpException} from '../exceptions/index.js';
@@ -97,7 +97,7 @@ export class Campaigns {
@CatchAsync
private async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.get(auth.projectId, id!);
@@ -116,7 +116,7 @@ export class Campaigns {
@CatchAsync
private async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
req.body;
@@ -162,7 +162,7 @@ export class Campaigns {
@CatchAsync
private async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
await CampaignService.delete(auth.projectId, id!);
@@ -181,7 +181,7 @@ export class Campaigns {
@CatchAsync
private async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.duplicate(auth.projectId, id!);
@@ -201,7 +201,7 @@ export class Campaigns {
@CatchAsync
private async send(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
const scheduledFor = req.body?.scheduledFor;
// Parse scheduledFor if provided
@@ -232,7 +232,7 @@ export class Campaigns {
@CatchAsync
private async cancel(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.cancel(auth.projectId, id!);
@@ -252,7 +252,7 @@ export class Campaigns {
@CatchAsync
private async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
const stats = await CampaignService.getStats(auth.projectId, id!);
@@ -271,7 +271,7 @@ export class Campaigns {
@CatchAsync
private async sendTest(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
const {email} = CampaignSchemas.sendTest.parse(req.body);
await CampaignService.sendTest(auth.projectId, id!, email);
+7 -6
View File
@@ -1,6 +1,7 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import multer from 'multer';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
@@ -63,7 +64,7 @@ export class Contacts {
count: fieldsWithTypes.length,
});
} catch (error) {
console.error('[CONTACTS] Failed to get available fields:', error);
signale.error('[CONTACTS] Failed to get available fields:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get available fields',
});
@@ -97,7 +98,7 @@ export class Contacts {
limit,
});
} catch (error) {
console.error('[CONTACTS] Failed to get field values:', error);
signale.error('[CONTACTS] Failed to get field values:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get field values',
});
@@ -288,7 +289,7 @@ export class Contacts {
jobId: job.id,
});
} catch (error) {
console.error('[CONTACTS] Failed to queue import:', error);
signale.error('[CONTACTS] Failed to queue import:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue import',
});
@@ -318,7 +319,7 @@ export class Contacts {
return res.status(200).json(status);
} catch (error) {
console.error('[CONTACTS] Failed to get import status:', error);
signale.error('[CONTACTS] Failed to get import status:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get import status',
});
@@ -345,7 +346,7 @@ export class Contacts {
const usage = await ContactService.getFieldUsage(auth.projectId!, field);
return res.status(200).json(usage);
} catch (error) {
console.error('[CONTACTS] Failed to get field usage:', error);
signale.error('[CONTACTS] Failed to get field usage:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get field usage',
});
@@ -372,7 +373,7 @@ export class Contacts {
const result = await ContactService.deleteField(auth.projectId!, field);
return res.status(200).json(result);
} catch (error) {
console.error('[CONTACTS] Failed to delete field:', error);
signale.error('[CONTACTS] Failed to delete field:', error);
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
error: error instanceof Error ? error.message : 'Failed to delete field',
});
+3 -2
View File
@@ -1,5 +1,6 @@
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
@@ -118,7 +119,7 @@ export class Events {
const usage = await EventService.getEventUsage(auth.projectId!, eventName);
return res.status(200).json(usage);
} catch (error) {
console.error('[EVENTS] Failed to get event usage:', error);
signale.error('[EVENTS] Failed to get event usage:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get event usage',
});
@@ -145,7 +146,7 @@ export class Events {
const result = await EventService.deleteEvent(auth.projectId!, eventName);
return res.status(200).json(result);
} catch (error) {
console.error('[EVENTS] Failed to delete event:', error);
signale.error('[EVENTS] Failed to delete event:', error);
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
error: error instanceof Error ? error.message : 'Failed to delete event',
});
+37 -4
View File
@@ -1,11 +1,12 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import {MembershipSchemas} from '@plunk/shared';
import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('projects')
@@ -19,7 +20,7 @@ export class Projects {
@CatchAsync
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
@@ -83,6 +84,38 @@ export class Projects {
});
}
/**
* Get project security metrics
* GET /projects/:id/security
*/
@Get(':id/security')
@Middleware([requireAuth])
@CatchAsync
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
// Use existing SecurityService
const metrics = await SecurityService.getProjectSecurityMetrics(id);
return res.json({
success: true,
data: metrics,
});
}
/**
* Get all members of a project
* GET /projects/:id/members
@@ -92,7 +125,7 @@ export class Projects {
@CatchAsync
private async getMembers(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
@@ -141,7 +174,7 @@ export class Projects {
@CatchAsync
private async addMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
// Validate params
if (!id) {
+2 -1
View File
@@ -1,6 +1,7 @@
import {Controller, Middleware, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import multer from 'multer';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
@@ -66,7 +67,7 @@ export class Uploads {
size: req.file.size,
});
} catch (error) {
console.error('[UPLOADS] Failed to upload image:', error);
signale.error('[UPLOADS] Failed to upload image:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to upload image',
});
+59 -17
View File
@@ -1,13 +1,13 @@
import {randomBytes} from 'node:crypto';
import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
import {BillingLimitSchemas, ProjectSchemas} from '@plunk/shared';
import {BillingLimitSchemas, ProjectSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {NotAuthenticated, NotFound} from '../exceptions/index.js';
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {isAuthenticated} from '../middleware/auth.js';
import {BillingLimitService} from '../services/BillingLimitService.js';
@@ -63,6 +63,16 @@ export class Users {
throw new NotAuthenticated();
}
// Check if user is a member of any disabled project
const {hasDisabledProject, disabledProjectNames} = await SecurityService.userHasDisabledProject(auth.userId);
if (hasDisabledProject) {
throw new HttpException(
403,
`You cannot create new projects while you are a member of disabled projects: ${disabledProjectNames.join(', ')}. Please contact support to resolve security violations.`,
ErrorCode.PROJECT_DISABLED,
);
}
const {name} = ProjectSchemas.create.parse(req.body);
// Generate unique API keys
@@ -95,7 +105,7 @@ export class Users {
@CatchAsync
public async updateProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
const data = ProjectSchemas.update.parse(req.body);
// Verify user has access to this project
@@ -127,7 +137,7 @@ export class Users {
@CatchAsync
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({
@@ -179,7 +189,7 @@ export class Users {
@CatchAsync
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
@@ -258,7 +268,7 @@ export class Users {
@CatchAsync
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
@@ -308,7 +318,7 @@ export class Users {
@CatchAsync
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
@@ -341,7 +351,7 @@ export class Users {
@CatchAsync
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
@@ -368,11 +378,14 @@ export class Users {
throw new NotFound('Project not found or you do not have permission to update billing limits');
}
// Get the project
// Get the project with current limits
const project = await prisma.project.findUnique({
where: {id},
select: {
subscription: true,
billingLimitWorkflows: true,
billingLimitCampaigns: true,
billingLimitTransactional: true,
},
});
@@ -395,8 +408,17 @@ export class Users {
},
});
// Invalidate cache so new limits take effect immediately
await BillingLimitService.invalidateCache(id);
// Clear notification cache keys for limits that changed
// This allows new warning/limit emails to be sent when the new limits are reached
await BillingLimitService.clearNotificationCacheForChangedLimits(
id,
{
workflows: project.billingLimitWorkflows,
campaigns: project.billingLimitCampaigns,
transactional: project.billingLimitTransactional,
},
data,
);
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
@@ -408,7 +430,7 @@ export class Users {
@CatchAsync
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
@@ -534,7 +556,7 @@ export class Users {
@CatchAsync
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
@@ -622,7 +644,7 @@ export class Users {
@CatchAsync
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
@@ -655,7 +677,7 @@ export class Users {
@CatchAsync
public async resetProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
@@ -680,6 +702,16 @@ export class Users {
throw new NotFound('Project not found or you do not have permission to reset it');
}
// Check if project is disabled - block reset operation
const isDisabled = await SecurityService.isProjectDisabled(id);
if (isDisabled) {
throw new HttpException(
403,
'Cannot reset a disabled project. Please contact support to resolve security violations before making changes.',
ErrorCode.PROJECT_DISABLED,
);
}
// Delete all project data in a transaction
await prisma.$transaction(async tx => {
// Delete all emails
@@ -731,7 +763,7 @@ export class Users {
@CatchAsync
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
@@ -758,13 +790,14 @@ export class Users {
);
}
// Get project to check for active subscription
// Get project to check for active subscription and disabled status
const project = await prisma.project.findUnique({
where: {id},
select: {
name: true,
subscription: true,
customer: true,
disabled: true,
},
});
@@ -772,6 +805,15 @@ export class Users {
throw new NotFound('Project not found');
}
// Check if project is disabled - block delete operation
if (project.disabled) {
throw new HttpException(
403,
'Cannot delete a disabled project. Please contact support to resolve security violations.',
ErrorCode.PROJECT_DISABLED,
);
}
// If project has an active subscription, cancel it first
if (STRIPE_ENABLED && stripe && project.subscription) {
try {
+2 -1
View File
@@ -1,6 +1,7 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {WorkflowExecutionStatus} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
@@ -45,7 +46,7 @@ export class Workflows {
return res.status(200).json(result);
} catch (error) {
console.error('[WORKFLOWS] Failed to get available fields:', error);
signale.error('[WORKFLOWS] Failed to get available fields:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get available fields',
});
+6 -5
View File
@@ -4,6 +4,7 @@
*/
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {CampaignService} from '../services/CampaignService.js';
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
@@ -14,11 +15,11 @@ export function createCampaignWorker() {
async (job: Job<CampaignBatchJobData>) => {
const {campaignId, batchNumber, offset, limit, cursor} = job.data;
console.log(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
signale.info(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
await CampaignService.processBatch(campaignId, batchNumber, offset, limit, cursor);
console.log(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
signale.info(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
},
{
connection: campaignQueue.opts.connection,
@@ -27,15 +28,15 @@ export function createCampaignWorker() {
);
worker.on('completed', job => {
console.log(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
signale.info(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
signale.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
signale.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
});
return worker;
+93 -10
View File
@@ -3,18 +3,50 @@
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
*/
import {EmailSourceType, EmailStatus} from '@plunk/db';
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {EmailService} from '../services/EmailService.js';
import {EventService} from '../services/EventService.js';
import {MeterService} from '../services/MeterService.js';
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
import {sendRawEmail} from '../services/SESService.js';
import {DASHBOARD_URI} from '../app/constants.js';
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
export function createEmailWorker() {
/**
* Determine the email sending rate limit (emails per second)
* Priority: ENV variable > AWS SES quota > Safe default (14)
*/
async function getEmailRateLimit(): Promise<number> {
const DEFAULT_RATE_LIMIT = 14; // AWS SES sandbox limit - safe default
// If env variable is set, use it (override)
if (EMAIL_RATE_LIMIT_PER_SECOND !== undefined) {
signale.info(`[EMAIL-PROCESSOR] Using rate limit from environment: ${EMAIL_RATE_LIMIT_PER_SECOND} emails/second`);
return EMAIL_RATE_LIMIT_PER_SECOND;
}
// Try to fetch from AWS SES
signale.info('[EMAIL-PROCESSOR] Fetching rate limit from AWS SES...');
const quota = await getSendingQuota();
if (quota) {
signale.info(
`[EMAIL-PROCESSOR] AWS SES quota: ${quota.maxSendRate} emails/second (${quota.sentLast24Hours}/${quota.max24HourSend} emails sent today)`,
);
return quota.maxSendRate;
}
// Fallback to safe default
signale.warn(`[EMAIL-PROCESSOR] Failed to fetch AWS quota, using safe default: ${DEFAULT_RATE_LIMIT} emails/second`);
return DEFAULT_RATE_LIMIT;
}
export async function createEmailWorker() {
// Fetch the rate limit (from env, AWS, or default)
const rateLimit = await getEmailRateLimit();
const worker = new Worker<SendEmailJobData>(
emailQueue.name,
async (job: Job<SendEmailJobData>) => {
@@ -38,7 +70,7 @@ export function createEmailWorker() {
// Check if project is disabled
if (email.project.disabled) {
console.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
signale.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
await prisma.email.update({
where: {id: emailId},
data: {
@@ -138,8 +170,59 @@ export function createEmailWorker() {
sourceType: email.sourceType,
sentAt: new Date().toISOString(),
});
// If this email belongs to a campaign, check if all campaign emails have been sent
if (email.campaignId) {
const campaign = await prisma.campaign.findUnique({
where: {id: email.campaignId},
select: {
id: true,
name: true,
status: true,
totalRecipients: true,
projectId: true,
project: {
select: {name: true},
},
},
});
// Only check if campaign is still in SENDING status
if (campaign && campaign.status === CampaignStatus.SENDING) {
// Count how many emails have been sent for this campaign
const sentCount = await prisma.email.count({
where: {
campaignId: email.campaignId,
sentAt: {not: null},
},
});
// If all emails have been sent, mark campaign as SENT
if (sentCount >= campaign.totalRecipients) {
await prisma.campaign.update({
where: {id: email.campaignId},
data: {
status: CampaignStatus.SENT,
},
});
signale.success(
`[EMAIL-PROCESSOR] Campaign ${campaign.name} completed: ${sentCount}/${campaign.totalRecipients} emails sent`,
);
// Send notification about campaign send completed
const {NtfyService} = await import('../services/NtfyService.js');
await NtfyService.notifyCampaignSendCompleted(
campaign.name,
campaign.project.name,
campaign.projectId,
campaign.totalRecipients,
);
}
}
}
} catch (error) {
console.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
signale.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
// Mark as failed
await prisma.email.update({
@@ -157,22 +240,22 @@ export function createEmailWorker() {
connection: emailQueue.opts.connection,
concurrency: 10, // Process up to 10 emails concurrently
limiter: {
max: 25, // Max 25 emails per second
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
duration: 1000,
},
},
);
worker.on('completed', job => {
console.log(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
signale.info(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
signale.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[EMAIL-PROCESSOR] Worker error:', err);
signale.error('[EMAIL-PROCESSOR] Worker error:', err);
});
return worker;
+8 -7
View File
@@ -5,6 +5,7 @@
import {type Job, Worker} from 'bullmq';
import {parse} from 'csv-parse/sync';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
@@ -28,7 +29,7 @@ export function createImportWorker() {
async (job: Job<ContactImportJobData>) => {
const {projectId, csvData, filename} = job.data;
console.log(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
signale.info(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
// Fetch project information for notifications
const project = await prisma.project.findUnique({
@@ -66,7 +67,7 @@ export function createImportWorker() {
throw new Error('CSV file is empty');
}
console.log(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
signale.info(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
// Notify that import has started
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
@@ -151,7 +152,7 @@ export function createImportWorker() {
await job.updateProgress(progress);
}
console.log(
signale.info(
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
);
@@ -168,7 +169,7 @@ export function createImportWorker() {
return result;
} catch (error) {
console.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
signale.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
// Notify that import has failed
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@@ -191,15 +192,15 @@ export function createImportWorker() {
);
worker.on('completed', job => {
console.log(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
signale.info(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
signale.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[IMPORT-PROCESSOR] Worker error:', err);
signale.error('[IMPORT-PROCESSOR] Worker error:', err);
});
return worker;
+9 -8
View File
@@ -5,6 +5,7 @@
import {CampaignStatus} from '@plunk/db';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {CampaignService} from '../services/CampaignService.js';
@@ -16,7 +17,7 @@ export function createScheduledCampaignWorker() {
async (job: Job<ScheduledCampaignJobData>) => {
const {campaignId} = job.data;
console.log(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
signale.info(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
// Get campaign with project
const campaign = await prisma.campaign.findUnique({
@@ -29,13 +30,13 @@ export function createScheduledCampaignWorker() {
});
if (!campaign) {
console.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
signale.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
return;
}
// Check if project is disabled
if (campaign.project.disabled) {
console.warn(
signale.warn(
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
);
await prisma.campaign.update({
@@ -47,7 +48,7 @@ export function createScheduledCampaignWorker() {
// Verify campaign is still in SCHEDULED status
if (campaign.status !== CampaignStatus.SCHEDULED) {
console.warn(
signale.warn(
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
);
return;
@@ -56,7 +57,7 @@ export function createScheduledCampaignWorker() {
// Start sending the campaign
await CampaignService.startSending(campaign.projectId, campaignId);
console.log(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
signale.info(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
},
{
connection: scheduledQueue.opts.connection,
@@ -65,15 +66,15 @@ export function createScheduledCampaignWorker() {
);
worker.on('completed', job => {
console.log(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
signale.info(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
signale.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[SCHEDULED-PROCESSOR] Worker error:', err);
signale.error('[SCHEDULED-PROCESSOR] Worker error:', err);
});
return worker;
+1 -1
View File
@@ -25,7 +25,7 @@ async function startWorkers() {
try {
// Start email worker
const emailWorker = createEmailWorker();
const emailWorker = await createEmailWorker();
workers.push({name: 'email', worker: emailWorker});
signale.success('[WORKER] Email worker started');
@@ -4,6 +4,7 @@
*/
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
@@ -33,15 +34,15 @@ export function createWorkflowWorker() {
);
worker.on('completed', job => {
console.log(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
signale.info(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
signale.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[WORKFLOW-PROCESSOR] Worker error:', err);
signale.error('[WORKFLOW-PROCESSOR] Worker error:', err);
});
return worker;
+6 -4
View File
@@ -1,7 +1,9 @@
import type {Prisma} from '@plunk/db';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
/**
* Activity types that can be tracked
@@ -170,7 +172,7 @@ export class ActivityService {
*/
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
// Try to get from cache
const cacheKey = `activity:stats:${projectId}:${startDate?.getTime() || 'all'}:${endDate?.getTime() || 'now'}`;
const cacheKey = Keys.Activity.stats(projectId, startDate?.getTime() || 'all', endDate?.getTime() || 'now');
try {
const cached = await redis.get(cacheKey);
@@ -178,7 +180,7 @@ export class ActivityService {
return JSON.parse(cached);
}
} catch (error) {
console.warn('[ACTIVITY] Failed to get stats from cache:', error);
signale.warn('[ACTIVITY] Failed to get stats from cache:', error);
}
// Default date range to last 30 days if not specified
@@ -241,7 +243,7 @@ export class ActivityService {
try {
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
} catch (error) {
console.warn('[ACTIVITY] Failed to cache stats:', error);
signale.warn('[ACTIVITY] Failed to cache stats:', error);
}
return stats;
@@ -261,7 +263,7 @@ export class ActivityService {
await redis.del(...keys);
}
} catch (error) {
console.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
signale.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
}
}
+4 -3
View File
@@ -1,5 +1,6 @@
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
/**
* Time series data point for analytics
@@ -57,7 +58,7 @@ export class AnalyticsService {
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
// Check cache first
const cacheKey = `analytics:timeseries:${projectId}:${limitedStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
const cacheKey = Keys.Analytics.timeseries(projectId, limitedStartDate.toISOString(), effectiveEndDate.toISOString());
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
@@ -190,7 +191,7 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = `analytics:campaignStats:${projectId}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
const cacheKey = Keys.Analytics.campaignStats(projectId, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
@@ -294,7 +295,7 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = `analytics:topEvents:${projectId}:${limit}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
const cacheKey = Keys.Analytics.topEvents(projectId, limit, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
+210 -5
View File
@@ -1,9 +1,13 @@
import {EmailSourceType} from '@plunk/db';
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {NtfyService} from './NtfyService.js';
/**
@@ -221,7 +225,8 @@ export class BillingLimitService {
project.billingLimitTransactional !== null;
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
if (!project.subscription && !hasCustomLimits) {
// Only enforce free tier limits if billing is enabled
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
const totalUsage = await this.getTotalUsage(projectId);
const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
const percentage = (totalUsage / freeLimit) * 100;
@@ -236,6 +241,9 @@ export class BillingLimitService {
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
);
// Send email notification
await this.sendLimitExceededEmail(projectId, project.name, totalUsage, freeLimit, 'Free Tier (All Types)');
return {
allowed: false,
warning: false,
@@ -257,6 +265,16 @@ export class BillingLimitService {
percentage,
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
);
// Send email notification (only once per month)
await this.sendWarningEmail(
projectId,
project.name,
totalUsage,
freeLimit,
percentage,
'Free Tier (All Types)',
);
}
return {
@@ -297,6 +315,9 @@ export class BillingLimitService {
if (project) {
// Send notification about limit exceeded
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
// Send email notification
await this.sendLimitExceededEmail(projectId, project.name, usage, limit, sourceType);
}
return {
@@ -320,7 +341,17 @@ export class BillingLimitService {
});
if (project) {
await NtfyService.notifyBillingLimitApproaching(project.name, projectId, usage, limit, percentage, sourceType);
await NtfyService.notifyBillingLimitApproaching(
project.name,
projectId,
usage,
limit,
percentage,
sourceType,
);
// Send email notification (only once per month)
await this.sendWarningEmail(projectId, project.name, usage, limit, percentage, sourceType);
}
}
@@ -415,7 +446,8 @@ export class BillingLimitService {
project.billingLimitTransactional !== null;
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
if (!project.subscription && !hasCustomLimits) {
// Only show free tier limits if billing is enabled
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
const limit = this.FREE_TIER_TOTAL_LIMIT;
const percentage = (totalUsage / limit) * 100;
@@ -455,7 +487,11 @@ export class BillingLimitService {
/**
* Invalidate usage cache for a project
* Call this when billing period resets or limits are changed
* Primarily used in tests to reset cache state between scenarios.
* In production, the cache naturally expires after 5 minutes.
*
* NOTE: Updating billing limits does NOT require clearing usage cache
* (use clearNotificationCacheForChangedLimits instead)
*
* @param projectId - Project ID
*/
@@ -474,6 +510,69 @@ export class BillingLimitService {
}
}
/**
* Clear notification cache keys for billing limits that have changed
* This allows new warning/limit emails to be sent when updated limits are reached
*
* @param projectId - Project ID
* @param oldLimits - Previous billing limits
* @param newLimits - New billing limits
*/
public static async clearNotificationCacheForChangedLimits(
projectId: string,
oldLimits: {
workflows: number | null;
campaigns: number | null;
transactional: number | null;
},
newLimits: {
workflows: number | null;
campaigns: number | null;
transactional: number | null;
},
): Promise<void> {
try {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const keysToDelete: string[] = [];
// Check workflows limit
if (oldLimits.workflows !== newLimits.workflows) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.WORKFLOW, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.WORKFLOW, year, month),
);
}
// Check campaigns limit
if (oldLimits.campaigns !== newLimits.campaigns) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
);
}
// Check transactional limit
if (oldLimits.transactional !== newLimits.transactional) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
);
}
if (keysToDelete.length > 0) {
await Promise.all(keysToDelete.map(key => redis.del(key)));
signale.debug(
`[BILLING_LIMIT] Cleared notification cache for changed limits in project ${projectId} (${keysToDelete.length} keys)`,
);
}
} catch (error) {
signale.warn(`[BILLING_LIMIT] Failed to clear notification cache for ${projectId}:`, error);
}
}
/**
* Get Redis cache key for usage count
*/
@@ -481,7 +580,7 @@ export class BillingLimitService {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
return `billing:usage:${projectId}:${sourceType}:${year}-${month}`;
return Keys.Billing.usage(projectId, sourceType, year, month);
}
/**
@@ -493,4 +592,110 @@ export class BillingLimitService {
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return {start, end};
}
/**
* Send billing limit warning email to project members
*/
private static async sendWarningEmail(
projectId: string,
projectName: string,
usage: number,
limit: number,
percentage: number,
sourceType: string,
): Promise<void> {
try {
// Check if we've already sent this warning email this month
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
const alreadySent = await redis.get(cacheKey);
if (alreadySent === '1') {
return;
}
const members = await prisma.membership.findMany({
where: {projectId},
include: {user: {select: {email: true}}},
});
const emails = members.map(m => m.user.email);
if (emails.length === 0) {
return;
}
const template = React.createElement(BillingLimitWarningEmail, {
projectName,
projectId,
usage,
limit,
percentage,
sourceType,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
// Mark that we've sent the warning email (expires at end of month)
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
await redis.setex(cacheKey, ttl, '1');
} catch (error) {
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
}
}
/**
* Send billing limit exceeded email to project members
*/
private static async sendLimitExceededEmail(
projectId: string,
projectName: string,
usage: number,
limit: number,
sourceType: string,
): Promise<void> {
try {
// Check if we've already sent this warning email this month
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
const alreadySent = await redis.get(cacheKey);
if (alreadySent === '1') {
return;
}
const members = await prisma.membership.findMany({
where: {projectId},
include: {user: {select: {email: true}}},
});
const emails = members.map(m => m.user.email);
if (emails.length === 0) {
return;
}
const template = React.createElement(BillingLimitExceededEmail, {
projectName,
projectId,
usage,
limit,
sourceType,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
// Mark that we've sent the limit email (expires at end of month)
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
await redis.setex(cacheKey, ttl, '1');
} catch (error) {
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
}
}
}
+63 -44
View File
@@ -1,17 +1,19 @@
import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
import {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js';
import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {SegmentService} from './SegmentService.js';
import {DASHBOARD_URI} from '../app/constants.js';
import {DASHBOARD_URI, STRIPE_ENABLED} from '../app/constants.js';
import {sendRawEmail} from './SESService.js';
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
@@ -22,8 +24,8 @@ export interface CreateCampaignData {
subject: string;
body: string;
from: string;
fromName?: string;
replyTo?: string;
fromName?: string | null;
replyTo?: string | null;
audienceType: CampaignAudienceType;
audienceCondition?: FilterCondition;
segmentId?: string;
@@ -35,8 +37,8 @@ export interface UpdateCampaignData {
subject?: string;
body?: string;
from?: string;
fromName?: string;
replyTo?: string;
fromName?: string | null;
replyTo?: string | null;
audienceType?: CampaignAudienceType;
audienceCondition?: FilterCondition;
segmentId?: string;
@@ -71,7 +73,7 @@ export class CampaignService {
SegmentService.validateCondition(data.audienceCondition);
}
// Create campaign
// Create campaign with initial recipient count of 0
const campaign = await prisma.campaign.create({
data: {
projectId,
@@ -86,6 +88,7 @@ export class CampaignService {
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
segmentId: data.segmentId,
status: CampaignStatus.DRAFT,
totalRecipients: 0, // Will be updated below
},
include: {
project: {
@@ -94,10 +97,17 @@ export class CampaignService {
},
});
// Calculate and update recipient count for the draft
const recipientCount = await this.getRecipientCount(projectId, campaign);
const updatedCampaign = await prisma.campaign.update({
where: {id: campaign.id},
data: {totalRecipients: recipientCount},
});
// Send notification about campaign creation
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
return campaign;
return updatedCampaign;
}
/**
@@ -147,10 +157,25 @@ export class CampaignService {
delete (updateData as Record<string, unknown>).segmentId;
}
return prisma.campaign.update({
// Update the campaign first
const updatedCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: updateData,
});
// If audience-related fields changed and campaign is still a draft, recalculate totalRecipients
const audienceChanged =
data.audienceType !== undefined || data.segmentId !== undefined || data.audienceCondition !== undefined;
if (audienceChanged && updatedCampaign.status === CampaignStatus.DRAFT) {
const recipientCount = await this.getRecipientCount(projectId, updatedCampaign);
return prisma.campaign.update({
where: {id: campaignId},
data: {totalRecipients: recipientCount},
});
}
return updatedCampaign;
}
/**
@@ -252,7 +277,7 @@ export class CampaignService {
const campaign = await this.get(projectId, campaignId);
// Create a new campaign with the same data but reset status and stats
return prisma.campaign.create({
const duplicatedCampaign = await prisma.campaign.create({
data: {
projectId,
name: `${campaign.name} (Copy)`,
@@ -266,7 +291,7 @@ export class CampaignService {
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
segmentId: campaign.segmentId,
status: CampaignStatus.DRAFT,
totalRecipients: 0,
totalRecipients: 0, // Will be updated below
sentCount: 0,
deliveredCount: 0,
openedCount: 0,
@@ -274,6 +299,13 @@ export class CampaignService {
bouncedCount: 0,
},
});
// Calculate and update recipient count
const recipientCount = await this.getRecipientCount(projectId, duplicatedCampaign);
return prisma.campaign.update({
where: {id: duplicatedCampaign.id},
data: {totalRecipients: recipientCount},
});
}
/**
@@ -294,6 +326,24 @@ export class CampaignService {
throw new HttpException(400, 'Campaign has no recipients');
}
// Check billing limits before scheduling/sending the campaign
// This ensures users cannot schedule campaigns that would exceed their quota
if (STRIPE_ENABLED) {
const limitCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
// If there's a limit set, verify the campaign won't exceed it
if (limitCheck.limit !== null) {
const projectedUsage = limitCheck.usage + recipientCount;
if (projectedUsage > limitCheck.limit) {
throw new HttpException(
403,
`Cannot ${scheduledFor ? 'schedule' : 'send'} campaign: would exceed billing limit. Current usage: ${limitCheck.usage}/${limitCheck.limit} emails, campaign recipients: ${recipientCount}. Upgrade your plan or reduce campaign recipients.`,
);
}
}
}
if (scheduledFor) {
// Schedule for later
if (scheduledFor.getTime() <= Date.now()) {
@@ -412,7 +462,7 @@ export class CampaignService {
}
if (campaign.status !== CampaignStatus.SENDING) {
console.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
signale.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
return;
}
@@ -458,21 +508,11 @@ export class CampaignService {
replyTo: campaign.replyTo || undefined,
});
} catch (error) {
console.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
// Continue with other contacts even if one fails
}
}
// Update sent count
await prisma.campaign.update({
where: {id: campaignId},
data: {
sentCount: {
increment: contacts.length,
},
},
});
// Queue next batch if there are more contacts
if (hasMore && nextCursor) {
await QueueService.queueCampaignBatch({
@@ -482,27 +522,6 @@ export class CampaignService {
limit,
cursor: nextCursor,
});
} else {
// All batches processed, mark campaign as SENT
const completedCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: {
status: CampaignStatus.SENT,
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about campaign send completed
await NtfyService.notifyCampaignSendCompleted(
completedCampaign.name,
completedCampaign.project.name,
completedCampaign.projectId,
completedCampaign.totalRecipients || 0,
);
}
}
+1 -1
View File
@@ -407,7 +407,7 @@ export class EmailService {
sentAt: new Date().toISOString(),
});
} catch (error) {
console.error(`[EMAIL] Failed to send email ${emailId}:`, error);
signale.error(`[EMAIL] Failed to send email ${emailId}:`, error);
// Mark as failed
await prisma.email.update({
+46 -13
View File
@@ -1,9 +1,12 @@
import type {Event} from '@plunk/db';
import {Prisma} from '@plunk/db';
import type {FilterCondition, FilterGroup} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {ValidationError} from '../exceptions/index.js';
import {Keys} from './keys.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
@@ -48,11 +51,11 @@ export class EventService {
* Should be called when workflows are enabled/disabled or updated
*/
public static async invalidateWorkflowCache(projectId: string): Promise<void> {
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
try {
await redis.del(cacheKey);
} catch (error) {
console.warn('[EVENT] Failed to invalidate workflow cache:', error);
signale.warn('[EVENT] Failed to invalidate workflow cache:', error);
}
}
@@ -287,9 +290,9 @@ export class EventService {
* @param eventName - The event name to delete
*/
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
// Prevent deletion of system events
if (eventName.startsWith('email.') || eventName.startsWith('segment.')) {
throw new Error('Cannot delete system events (email.* or segment.*)');
// Prevent deletion of reserved system events
if (this.isReservedEvent(eventName)) {
throw new Error(`Cannot delete reserved system event: ${eventName}`);
}
// Check if event is in use
@@ -311,6 +314,36 @@ export class EventService {
return {deletedCount: result.count};
}
/**
* Check if an event name is reserved for system use
* Reserved patterns:
* - email.* (email.sent, email.delivery, email.open, email.click, email.bounce, email.complaint)
* - contact.subscribed, contact.unsubscribed
* - segment.*.entry, segment.*.exit
*
* @param eventName - The event name to check
* @returns true if the event is reserved, false otherwise
*/
public static isReservedEvent(eventName: string): boolean {
// Email events: email.*
if (eventName.startsWith('email.')) {
return true;
}
// Contact events: contact.subscribed, contact.unsubscribed
if (eventName === 'contact.subscribed' || eventName === 'contact.unsubscribed') {
return true;
}
// Segment events: segment.*.entry, segment.*.exit
// Pattern: segment.<slug>.entry or segment.<slug>.exit
if (eventName.startsWith('segment.') && (eventName.endsWith('.entry') || eventName.endsWith('.exit'))) {
return true;
}
return false;
}
/**
* Trigger workflows based on an event
* Uses Redis caching for enabled workflows to improve performance
@@ -322,7 +355,7 @@ export class EventService {
data?: Record<string, unknown>,
): Promise<void> {
// Try to get workflows from cache
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
let workflows;
try {
@@ -331,7 +364,7 @@ export class EventService {
workflows = JSON.parse(cached);
}
} catch (error) {
console.warn('[EVENT] Failed to get workflows from cache:', error);
signale.warn('[EVENT] Failed to get workflows from cache:', error);
}
// If not in cache, fetch from database
@@ -353,7 +386,7 @@ export class EventService {
try {
await redis.setex(cacheKey, 300, JSON.stringify(workflows));
} catch (error) {
console.warn('[EVENT] Failed to cache workflows:', error);
signale.warn('[EVENT] Failed to cache workflows:', error);
}
}
@@ -368,7 +401,7 @@ export class EventService {
} else {
// If event is not contact-specific, you might want different logic
// For example, trigger for all contacts, or skip
console.log(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
signale.info(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
}
}
}
@@ -394,7 +427,7 @@ export class EventService {
});
if (!workflow || workflow.steps.length === 0) {
console.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
signale.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
return;
}
@@ -429,7 +462,7 @@ export class EventService {
const triggerStep = workflow.steps[0];
if (!triggerStep) {
console.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
signale.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
return;
}
@@ -444,14 +477,14 @@ export class EventService {
},
});
console.log(
signale.info(
`[EVENT] Started workflow ${workflowId} execution ${execution.id} for contact ${contactId}${workflow.allowReentry ? ' (re-entry allowed)' : ''}`,
);
// Start executing the workflow
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
} catch (error) {
console.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
signale.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
}
}
+7 -5
View File
@@ -6,6 +6,8 @@ import {
PutBucketPolicyCommand,
} from '@aws-sdk/client-s3';
import crypto from 'crypto';
import signale from 'signale';
import {
S3_ENDPOINT,
S3_ACCESS_KEY_ID,
@@ -69,13 +71,13 @@ export async function initializeBucket(): Promise<void> {
Bucket: S3_BUCKET,
}),
);
console.log(`[S3] Created bucket: ${S3_BUCKET}`);
signale.info(`[S3] Created bucket: ${S3_BUCKET}`);
} catch (createError) {
console.error('[S3] Failed to create bucket:', createError);
signale.error('[S3] Failed to create bucket:', createError);
throw createError;
}
} else {
console.error('[S3] Failed to check bucket:', error);
signale.error('[S3] Failed to check bucket:', error);
throw error;
}
}
@@ -103,10 +105,10 @@ export async function initializeBucket(): Promise<void> {
);
if (!bucketExists) {
console.log(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
signale.info(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
}
} catch (policyError) {
console.error('[S3] Failed to set bucket policy:', policyError);
signale.error('[S3] Failed to set bucket policy:', policyError);
// Don't throw - bucket was created but policy failed
}
}
+32 -2
View File
@@ -1,4 +1,5 @@
import {SES} from '@aws-sdk/client-ses';
import signale from 'signale';
import {
AWS_SES_ACCESS_KEY_ID,
@@ -6,7 +7,8 @@ import {
AWS_SES_SECRET_ACCESS_KEY,
DASHBOARD_URI,
SES_CONFIGURATION_SET,
SES_CONFIGURATION_SET_NO_TRACKING
SES_CONFIGURATION_SET_NO_TRACKING,
TRACKING_TOGGLE_ENABLED
} from '../app/constants.js';
/**
@@ -160,10 +162,15 @@ ${breakLongLines(attachment.content, 76, true)}`,
: ''
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
// Determine which configuration set to use
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
const configurationSetName =
TRACKING_TOGGLE_ENABLED && !tracking ? SES_CONFIGURATION_SET_NO_TRACKING : SES_CONFIGURATION_SET;
// Send via SES
const response = await ses.sendRawEmail({
Destinations: destinations,
ConfigurationSetName: tracking ? SES_CONFIGURATION_SET : SES_CONFIGURATION_SET_NO_TRACKING,
ConfigurationSetName: configurationSetName,
RawMessage: {
Data: new TextEncoder().encode(rawMessage),
},
@@ -250,3 +257,26 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
ForwardingEnabled: false,
});
};
/**
* Get AWS SES account sending quota and rate limit
* @returns MaxSendRate (emails per second) or null if the call fails
*/
export const getSendingQuota = async (): Promise<{
maxSendRate: number;
max24HourSend: number;
sentLast24Hours: number;
} | null> => {
try {
const quota = await ses.getSendQuota({});
return {
maxSendRate: quota.MaxSendRate ?? 14, // Default to sandbox limit if not provided
max24HourSend: quota.Max24HourSend ?? 200, // Default sandbox daily limit
sentLast24Hours: quota.SentLast24Hours ?? 0,
};
} catch (error) {
signale.error('[SES] Failed to fetch sending quota:', error);
return null;
}
};
+95 -12
View File
@@ -1,9 +1,13 @@
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
/**
* Security thresholds for bounce and complaint rates
@@ -45,7 +49,6 @@ interface SecurityStatus {
}
export class SecurityService {
private static readonly CACHE_PREFIX = 'security';
private static readonly CACHE_TTL = 300; // 5 minutes
/**
@@ -54,7 +57,7 @@ export class SecurityService {
public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> {
try {
// Try to get from cache first
const cacheKey = this.getCacheKey(projectId, 'rates');
const cacheKey = Keys.Security.rates(projectId);
const cached = await redis.get(cacheKey);
if (cached) {
@@ -107,9 +110,31 @@ export class SecurityService {
// Get current security status
const status = await this.getSecurityStatus(projectId);
// If project should be disabled, disable it
if (status.shouldDisable) {
// If project should be disabled, disable it (only if auto-disable is enabled)
if (status.shouldDisable && AUTO_PROJECT_DISABLE) {
await this.disableProject(projectId, status);
} else if (status.shouldDisable && !AUTO_PROJECT_DISABLE) {
// Log critical violations but don't auto-disable (self-hosted mode)
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
signale.error(
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
status.violations,
);
signale.info(
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
);
signale.info(
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
);
// Send notification about critical security violations
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
}
} else if (status.warnings.length > 0) {
// Log warnings for monitoring
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
@@ -137,13 +162,55 @@ export class SecurityService {
*/
public static async invalidateCache(projectId: string): Promise<void> {
try {
const cacheKey = this.getCacheKey(projectId, 'rates');
const cacheKey = Keys.Security.rates(projectId);
await redis.del(cacheKey);
} catch (error) {
signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, error);
}
}
/**
* Check if a user is a member of any disabled project
* Users with disabled projects cannot create new projects
*/
public static async userHasDisabledProject(userId: string): Promise<{
hasDisabledProject: boolean;
disabledProjectNames: string[];
}> {
const disabledMemberships = await prisma.membership.findMany({
where: {
userId,
project: {
disabled: true,
},
},
include: {
project: {
select: {
name: true,
},
},
},
});
return {
hasDisabledProject: disabledMemberships.length > 0,
disabledProjectNames: disabledMemberships.map(m => m.project.name),
};
}
/**
* Check if a specific project is disabled
*/
public static async isProjectDisabled(projectId: string): Promise<boolean> {
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
});
return project?.disabled ?? false;
}
/**
* Get a project's security metrics (for admin/dashboard display)
*/
@@ -167,13 +234,6 @@ export class SecurityService {
};
}
/**
* Get cache key for security metrics
*/
private static getCacheKey(projectId: string, type: 'rates'): string {
return `${this.CACHE_PREFIX}:${projectId}:${type}`;
}
/**
* Calculate bounce and complaint rates for a project
*/
@@ -345,6 +405,29 @@ export class SecurityService {
// Send urgent notification about project suspension
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
// Send email notification to project members
try {
const members = await prisma.membership.findMany({
where: {projectId},
include: {user: {select: {email: true}}},
});
const emails = members.map(m => m.user.email);
if (emails.length > 0) {
const template = React.createElement(ProjectDisabledEmail, {
projectName: project.name,
projectId,
violations: status.violations,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Security Risk', template)),
);
}
} catch (emailError) {
signale.error(`[SECURITY] Failed to send project disabled email:`, emailError);
}
} catch (error) {
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
}
+5 -4
View File
@@ -1,5 +1,6 @@
import {type Contact, Prisma, type Segment} from '@plunk/db';
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
@@ -276,7 +277,7 @@ export class SegmentService {
data: {memberCount},
});
} catch (error) {
console.error(`Failed to update count for segment ${segment.id}:`, error);
signale.error(`Failed to update count for segment ${segment.id}:`, error);
}
}),
);
@@ -383,7 +384,7 @@ export class SegmentService {
segmentName: segment.name,
});
} catch (error) {
console.error(`[SEGMENT] Failed to track segment entry event for contact ${contactId}:`, error);
signale.error(`[SEGMENT] Failed to track segment entry event for contact ${contactId}:`, error);
}
}
}
@@ -414,7 +415,7 @@ export class SegmentService {
segmentName: segment.name,
});
} catch (error) {
console.error(`[SEGMENT] Failed to track segment exit event for contact ${contactId}:`, error);
signale.error(`[SEGMENT] Failed to track segment exit event for contact ${contactId}:`, error);
}
}
}
@@ -425,7 +426,7 @@ export class SegmentService {
data: {memberCount: matchingContactIds.size},
});
console.log(
signale.info(
`[SEGMENT] Computed membership for segment ${segmentId}: added ${toAdd.length}, removed ${toRemove.length}, total ${matchingContactIds.size}`,
);
+4 -4
View File
@@ -88,8 +88,8 @@ export class TemplateService {
subject: string;
body: string;
from: string;
fromName?: string;
replyTo?: string;
fromName?: string | null;
replyTo?: string | null;
type?: Template['type'];
},
): Promise<Template> {
@@ -120,8 +120,8 @@ export class TemplateService {
subject?: string;
body?: string;
from?: string;
fromName?: string;
replyTo?: string;
fromName?: string | null;
replyTo?: string | null;
type?: Template['type'];
},
): Promise<Template> {
+2 -1
View File
@@ -1,5 +1,6 @@
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
@@ -786,7 +787,7 @@ export class WorkflowService {
// Start executing the workflow asynchronously
// Don't await - let it run in background
WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => {
console.error('Error executing workflow:', error);
signale.error('Error executing workflow:', error);
});
return execution;
@@ -5,6 +5,28 @@ import {EmailService} from '../EmailService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {redis} from '../../database/redis';
// Mock STRIPE_ENABLED and STRIPE_SK for free tier tests
vi.mock('../../app/constants.js', async () => {
const actual = await vi.importActual('../../app/constants.js');
return {
...actual,
STRIPE_ENABLED: true,
STRIPE_SK: 'sk_test_mock_key_for_testing',
};
});
// Mock the stripe client to avoid actual Stripe API calls
vi.mock('../../app/stripe.js', () => ({
stripe: {
customers: {
retrieve: vi.fn().mockResolvedValue({
deleted: false,
currency: 'usd',
}),
},
},
}));
describe('BillingLimitService - Critical Enforcement', () => {
let projectId: string;
let contactId: string;
@@ -1,8 +1,18 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
import {CampaignService} from '../CampaignService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock STRIPE_ENABLED for billing limit tests
vi.mock('../../app/constants.js', async () => {
const actual = await vi.importActual('../../app/constants.js');
return {
...actual,
STRIPE_ENABLED: true,
STRIPE_SK: 'sk_test_mock_key_for_testing',
};
});
describe('CampaignService', () => {
let projectId: string;
const prisma = getPrismaClient();
@@ -356,4 +366,227 @@ describe('CampaignService', () => {
expect(matching).toBe(5);
});
});
describe('send', () => {
it('should throw error when campaign has no recipients', async () => {
// Create campaign with no contacts in project
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
});
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow('Campaign has no recipients');
});
it('should send campaign successfully when recipients are under billing limit', async () => {
// Set billing limit for campaigns
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
// Create 5 subscribed contacts
for (let i = 0; i < 5; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
// Create campaign targeting all contacts
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully (5 recipients < 100 limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(5);
});
it('should throw 403 error when campaign would exceed billing limit', async () => {
// Set billing limit for campaigns to 10
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 5 existing campaign emails (usage = 5)
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create campaign with 10 subscribed contacts (would result in 15 total)
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should throw error because 5 + 11 = 16 > 10 limit (11 contacts: 1 from email creation + 10 new)
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(/exceed billing limit/i);
});
it('should throw 403 error when scheduled campaign would exceed billing limit', async () => {
// Set billing limit for campaigns to 20
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 20},
});
// Create 15 existing campaign emails
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 15; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create campaign with 10 subscribed contacts (would result in 25 total)
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Should throw error when scheduling because 15 + 10 = 25 > 20 limit
await expect(CampaignService.send(projectId, campaign.id, scheduledFor)).rejects.toThrow(
/Cannot schedule campaign.*exceed billing limit/i,
);
});
it('should send campaign successfully when billing limit is null (unlimited)', async () => {
// Set billing limit to null (unlimited)
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: null},
});
// Create 1000 subscribed contacts
for (let i = 0; i < 1000; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully (no limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(1000);
});
it('should allow campaign that exactly reaches billing limit', async () => {
// Set billing limit for campaigns to 10
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 5 existing campaign emails
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create 4 more subscribed contacts (total of 5 with the one above: 1 + 4 = 5)
for (let i = 0; i < 4; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully because 5 + 5 = 10 (exactly at limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(5);
});
it('should throw error when campaign has already been sent', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENT,
});
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(
'Campaign has already been sent or is currently sending',
);
});
it('should schedule campaign successfully when recipients are under billing limit', async () => {
// Set billing limit for campaigns
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 50},
});
// Create 10 subscribed contacts
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Should schedule successfully (10 recipients < 50 limit)
const scheduledCampaign = await CampaignService.send(projectId, campaign.id, scheduledFor);
expect(scheduledCampaign.status).toBe(CampaignStatus.SCHEDULED);
expect(scheduledCampaign.scheduledFor).toEqual(scheduledFor);
expect(scheduledCampaign.totalRecipients).toBe(10);
});
});
});
@@ -1,4 +1,4 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import {ActionSchemas} from '@plunk/shared';
import {EmailService} from '../EmailService';
@@ -669,7 +669,6 @@ describe('EmailService', () => {
// ========================================
describe('Attachment Schema Validation', () => {
it('should validate attachment count limit (max 10)', () => {
const tooManyAttachments = Array.from({length: 11}, (_, i) => ({
filename: `file${i}.txt`,
content: Buffer.from('content').toString('base64'),
@@ -690,7 +689,6 @@ describe('EmailService', () => {
});
it('should validate attachment size limit (10MB total)', () => {
// Exceeds ~13.3M base64 chars limit
const largeContent = 'A'.repeat(14000000);
@@ -711,11 +709,11 @@ describe('EmailService', () => {
});
it('should accept attachments within size limit', () => {
const validContent = Buffer.from('Small file content').toString('base64');
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
from: '[email protected]',
subject: 'Test',
body: 'Test',
attachments: [
@@ -731,7 +729,6 @@ describe('EmailService', () => {
});
it('should reject attachment with missing required fields', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
subject: 'Test',
@@ -748,7 +745,6 @@ describe('EmailService', () => {
});
it('should reject attachment with empty filename', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
subject: 'Test',
@@ -766,7 +762,6 @@ describe('EmailService', () => {
});
it('should reject attachment with filename exceeding 255 chars', () => {
const tooLongFilename = 'a'.repeat(256) + '.pdf';
const result = ActionSchemas.send.safeParse({
@@ -786,18 +781,12 @@ describe('EmailService', () => {
});
it('should accept valid attachment with various content types', () => {
const contentTypes = [
'application/pdf',
'image/png',
'image/jpeg',
'text/plain',
'application/zip',
];
const contentTypes = ['application/pdf', 'image/png', 'image/jpeg', 'text/plain', 'application/zip'];
for (const contentType of contentTypes) {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
from: '[email protected]',
subject: 'Test',
body: 'Test',
attachments: [
@@ -1,6 +1,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {WorkflowExecutionStatus, WorkflowTriggerType} from '@plunk/db';
import {EventService} from '../EventService';
import {Keys} from '../keys';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock Redis for caching tests - must be inline to avoid hoisting issues
@@ -503,7 +504,7 @@ describe('EventService', () => {
const {redis} = await import('../../database/redis');
// Set cache
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
await redis.set(cacheKey, JSON.stringify([{id: 'test'}]));
// Verify cache exists
@@ -852,4 +853,90 @@ describe('EventService', () => {
});
});
});
// ========================================
// RESERVED EVENT VALIDATION
// ========================================
describe('isReservedEvent', () => {
describe('Email events (email.*)', () => {
it('should identify email.sent as reserved', () => {
expect(EventService.isReservedEvent('email.sent')).toBe(true);
});
it('should identify email.delivery as reserved', () => {
expect(EventService.isReservedEvent('email.delivery')).toBe(true);
});
it('should identify email.open as reserved', () => {
expect(EventService.isReservedEvent('email.open')).toBe(true);
});
it('should identify email.click as reserved', () => {
expect(EventService.isReservedEvent('email.click')).toBe(true);
});
it('should identify email.bounce as reserved', () => {
expect(EventService.isReservedEvent('email.bounce')).toBe(true);
});
it('should identify email.complaint as reserved', () => {
expect(EventService.isReservedEvent('email.complaint')).toBe(true);
});
it('should identify any email.* pattern as reserved', () => {
expect(EventService.isReservedEvent('email.custom')).toBe(true);
expect(EventService.isReservedEvent('email.anything')).toBe(true);
});
});
describe('Contact events', () => {
it('should identify contact.subscribed as reserved', () => {
expect(EventService.isReservedEvent('contact.subscribed')).toBe(true);
});
it('should identify contact.unsubscribed as reserved', () => {
expect(EventService.isReservedEvent('contact.unsubscribed')).toBe(true);
});
it('should not identify other contact.* events as reserved', () => {
expect(EventService.isReservedEvent('contact.created')).toBe(false);
expect(EventService.isReservedEvent('contact.updated')).toBe(false);
});
});
describe('Segment events (segment.*.entry, segment.*.exit)', () => {
it('should identify segment.*.entry as reserved', () => {
expect(EventService.isReservedEvent('segment.vip-users.entry')).toBe(true);
expect(EventService.isReservedEvent('segment.premium.entry')).toBe(true);
expect(EventService.isReservedEvent('segment.active-subscribers.entry')).toBe(true);
});
it('should identify segment.*.exit as reserved', () => {
expect(EventService.isReservedEvent('segment.vip-users.exit')).toBe(true);
expect(EventService.isReservedEvent('segment.premium.exit')).toBe(true);
expect(EventService.isReservedEvent('segment.active-subscribers.exit')).toBe(true);
});
it('should not identify other segment.* events as reserved', () => {
expect(EventService.isReservedEvent('segment.created')).toBe(false);
expect(EventService.isReservedEvent('segment.vip-users.updated')).toBe(false);
expect(EventService.isReservedEvent('segment.premium')).toBe(false);
});
});
describe('Non-reserved events', () => {
it('should not identify custom user events as reserved', () => {
expect(EventService.isReservedEvent('user.signup')).toBe(false);
expect(EventService.isReservedEvent('purchase.completed')).toBe(false);
expect(EventService.isReservedEvent('order.placed')).toBe(false);
expect(EventService.isReservedEvent('custom.event')).toBe(false);
});
it('should not identify events with similar prefixes as reserved', () => {
expect(EventService.isReservedEvent('emails.sent')).toBe(false);
expect(EventService.isReservedEvent('contacts.subscribed')).toBe(false);
expect(EventService.isReservedEvent('segments.entry')).toBe(false);
});
});
});
});
@@ -1,6 +1,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
import {WorkflowService} from '../WorkflowService';
import {Keys} from '../keys';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock Redis for caching tests - must be inline to avoid hoisting issues
@@ -130,7 +131,7 @@ describe('WorkflowService', () => {
it('should invalidate cache when creating enabled workflow', async () => {
const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
// Set cache
await redis.set(cacheKey, JSON.stringify([{id: 'old'}]));
@@ -286,7 +287,7 @@ describe('WorkflowService', () => {
it('should invalidate cache when enabling workflow', async () => {
const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
const workflow = await factories.createWorkflow({
projectId,
@@ -326,7 +327,7 @@ describe('WorkflowService', () => {
it('should invalidate cache when deleting enabled workflow', async () => {
const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
const workflow = await factories.createWorkflow({
projectId,
+37
View File
@@ -15,4 +15,41 @@ export const Keys = {
return `domain:project:${projectId}`;
},
},
Billing: {
usage(projectId: string, sourceType: string, year: number, month: string): string {
return `billing:usage:${projectId}:${sourceType}:${year}-${month}`;
},
warningEmail(projectId: string, sourceType: string, year: number, month: string): string {
return `billing:warning_email:${projectId}:${sourceType}:${year}-${month}`;
},
limitEmail(projectId: string, sourceType: string, year: number, month: string): string {
return `billing:limit_email:${projectId}:${sourceType}:${year}-${month}`;
},
},
Security: {
rates(projectId: string): string {
return `security:${projectId}:rates`;
},
},
Activity: {
stats(projectId: string, startTime: number | string, endTime: number | string): string {
return `activity:stats:${projectId}:${startTime}:${endTime}`;
},
},
Analytics: {
timeseries(projectId: string, startDate: string, endDate: string): string {
return `analytics:timeseries:${projectId}:${startDate}:${endDate}`;
},
campaignStats(projectId: string, startDate: string, endDate: string): string {
return `analytics:campaignStats:${projectId}:${startDate}:${endDate}`;
},
topEvents(projectId: string, limit: number, startDate: string, endDate: string): string {
return `analytics:topEvents:${projectId}:${limit}:${startDate}:${endDate}`;
},
},
Workflow: {
enabled(projectId: string): string {
return `workflows:enabled:${projectId}`;
},
},
} as const;
+2 -2
View File
@@ -34,8 +34,8 @@ export function buildEmailFieldsUpdate(data: {
subject?: string;
body?: string;
from?: string;
fromName?: string;
replyTo?: string;
fromName?: string | null;
replyTo?: string | null;
}): Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput {
return buildUpdateData(data) as Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput;
}
+3 -3
View File
@@ -15,10 +15,10 @@
"@plunk/shared": "*",
"@plunk/ui": "*",
"lucide-react": "^0.553.0",
"next": "^16.0.7",
"next": "^16.0.10",
"next-seo": "^6.6.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.6"
},
"devDependencies": {
+9 -3
View File
@@ -11,6 +11,9 @@
"clean": "rimraf node_modules .next .turbo"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.11",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.0",
"@plunk/db": "*",
"@plunk/shared": "*",
"@plunk/ui": "*",
@@ -19,6 +22,7 @@
"@tiptap/extension-image": "^3.11.0",
"@tiptap/extension-link": "^3.11.0",
"@tiptap/extension-mention": "^3.11.0",
"@tiptap/extension-placeholder": "^3.11.0",
"@tiptap/extension-table": "^3.11.0",
"@tiptap/extension-table-cell": "^3.11.0",
"@tiptap/extension-table-header": "^3.11.0",
@@ -30,16 +34,18 @@
"@tiptap/starter-kit": "^3.11.0",
"@tiptap/suggestion": "^3.11.0",
"@types/dagre": "^0.7.53",
"@uiw/react-codemirror": "^4.25.3",
"@xyflow/react": "^12.9.3",
"dagre": "^0.8.5",
"dayjs": "^1.11.19",
"framer-motion": "^12.23.24",
"juice": "^11.0.3",
"lucide-react": "^0.553.0",
"next": "^16.0.7",
"next": "^16.0.10",
"next-seo": "^6.6.0",
"nuqs": "^2.7.3",
"react": "19.2.1",
"react-dom": "19.2.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.66.0",
"react-intl": "^7.1.14",
"react-is": "^19.0.0",
+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 {zodResolver} from '@hookform/resolvers/zod';
import {BillingLimitSchemas} from '@plunk/shared';
@@ -23,7 +23,7 @@ import {
import {AlertCircle, AlertTriangle, Check} from 'lucide-react';
import {AnimatePresence, motion} from 'framer-motion';
import type {z} from 'zod';
import {useBillingLimits, type BillingLimitsData, type CategoryLimit} from '../lib/hooks/useBillingLimits';
import {type BillingLimitsData, type CategoryLimit, useBillingLimits} from '../lib/hooks/useBillingLimits';
import {network} from '../lib/network';
// Price per email in the smallest currency unit (e.g., cents for USD/EUR)
@@ -50,7 +50,7 @@ const formatEmailCost = (emailCount: number, currency: string | null): string =>
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(costInCents / 100);
} catch (error) {
} catch {
// Fallback if currency is invalid
return `${(costInCents / 100).toFixed(2)} ${currency.toUpperCase()}`;
}
@@ -167,8 +167,8 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
You&apos;re on the free tier with 1,000 emails per month. Upgrade to a paid subscription for
unlimited emails or custom limits.
You&apos;re on the free tier with 1,000 emails per month. Upgrade to a paid subscription for unlimited
emails or custom limits.
</p>
</div>
</Alert>
@@ -254,9 +254,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
</FormControl>
<FormDescription>
Maximum workflow emails per month. Leave empty for unlimited.
{estimatedCost && (
<span className="text-neutral-500"> {estimatedCost}/month</span>
)}
{estimatedCost && <span className="text-neutral-500"> {estimatedCost}/month</span>}
</FormDescription>
<FormMessage />
</FormItem>
@@ -286,9 +284,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
</FormControl>
<FormDescription>
Maximum campaign emails per month. Leave empty for unlimited.
{estimatedCost && (
<span className="text-neutral-500"> {estimatedCost}/month</span>
)}
{estimatedCost && <span className="text-neutral-500"> {estimatedCost}/month</span>}
</FormDescription>
<FormMessage />
</FormItem>
@@ -318,9 +314,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
</FormControl>
<FormDescription>
Maximum transactional emails per month. Leave empty for unlimited.
{estimatedCost && (
<span className="text-neutral-500"> {estimatedCost}/month</span>
)}
{estimatedCost && <span className="text-neutral-500"> {estimatedCost}/month</span>}
</FormDescription>
<FormMessage />
</FormItem>
@@ -412,7 +406,7 @@ const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: Usa
<Alert className="mt-3 bg-red-50 border-red-200 text-red-900">
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-xs">
<p className={'text-sm'}>
<strong>Limit reached:</strong> No more {category.toLowerCase()} emails can be sent this month.
</p>
</div>
@@ -423,7 +417,7 @@ const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: Usa
<Alert className="mt-3 bg-orange-50 border-orange-200 text-orange-900">
<AlertTriangle className="h-4 w-4" />
<div className="ml-2">
<p className="text-xs">
<p className="text-sm">
<strong>Warning:</strong> You&apos;ve used {Math.round(usage.percentage)}% of your{' '}
{category.toLowerCase()} email limit.
</p>
@@ -0,0 +1,424 @@
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import type {Campaign} from '@plunk/db';
import {CampaignStatus} from '@plunk/db';
import {ArrowLeft, Calendar, Mail, Users} from 'lucide-react';
import {useState} from 'react';
import useSWR from 'swr';
interface PaginatedCampaigns {
campaigns: Campaign[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
interface CampaignSelectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelectCampaign: (campaign: Campaign, selectedFields: SelectedFields) => void;
}
interface SelectedFields {
subject: boolean;
body: boolean;
from: boolean;
fromName: boolean;
replyTo: boolean;
audience: boolean;
}
export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: CampaignSelectionDialogProps) {
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>('ALL');
const [step, setStep] = useState<'select' | 'configure'>('select');
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
const [selectedFields, setSelectedFields] = useState<SelectedFields>({
subject: true,
body: true,
from: true,
fromName: true,
replyTo: true,
audience: true,
});
const {data, isLoading} = useSWR<PaginatedCampaigns>(
open
? `/campaigns?page=${page}&pageSize=10${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`
: null,
{revalidateOnFocus: false},
);
const handleCampaignClick = (campaign: Campaign) => {
setSelectedCampaign(campaign);
setStep('configure');
};
const handleBack = () => {
setStep('select');
setSelectedCampaign(null);
};
const handleConfirm = () => {
if (selectedCampaign) {
onSelectCampaign(selectedCampaign, selectedFields);
onOpenChange(false);
// Reset state
setPage(1);
setStatusFilter('ALL');
setStep('select');
setSelectedCampaign(null);
setSelectedFields({
subject: true,
body: true,
from: true,
fromName: true,
replyTo: true,
audience: true,
});
}
};
const toggleField = (field: keyof SelectedFields) => {
setSelectedFields(prev => ({...prev, [field]: !prev[field]}));
};
const getStatusBadge = (status: CampaignStatus) => {
const variants: Record<
CampaignStatus,
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; className?: string}
> = {
DRAFT: {variant: 'secondary', label: 'Draft', className: 'bg-neutral-100 text-neutral-700'},
SCHEDULED: {variant: 'default', label: 'Scheduled', className: 'bg-blue-100 text-blue-700'},
SENDING: {variant: 'default', label: 'Sending', className: 'bg-purple-100 text-purple-700'},
SENT: {variant: 'default', label: 'Sent', className: 'bg-green-100 text-green-700'},
CANCELLED: {variant: 'destructive', label: 'Cancelled', className: 'bg-red-100 text-red-700'},
};
const config = variants[status];
return (
<Badge variant={config.variant} className={config.className}>
{config.label}
</Badge>
);
};
const getAudienceLabel = (campaign: Campaign) => {
if (campaign.audienceType === 'ALL') {
return 'All Contacts';
} else if (campaign.audienceType === 'SEGMENT') {
return `Segment (${campaign.totalRecipients.toLocaleString()} contacts)`;
} else {
return `Filtered (${campaign.totalRecipients.toLocaleString()} contacts)`;
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{step === 'configure' && (
<Button variant="ghost" size="sm" onClick={handleBack} className="h-8 w-8 p-0">
<ArrowLeft className="h-4 w-4" />
</Button>
)}
{step === 'select' ? 'Select a Campaign' : 'Choose What to Copy'}
</DialogTitle>
<DialogDescription>
{step === 'select'
? 'Choose a previous campaign to use as a starting point'
: 'Select which parts of the campaign you want to use'}
</DialogDescription>
</DialogHeader>
{step === 'select' ? (
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
{/* Status Filter */}
<div className="space-y-3">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Campaigns</SelectItem>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
<SelectItem value="SENDING">Sending</SelectItem>
<SelectItem value="SENT">Sent</SelectItem>
<SelectItem value="CANCELLED">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
{/* Campaigns List */}
<div className="flex-1 overflow-y-auto space-y-3">
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading campaigns...</p>
</div>
</div>
)}
{!isLoading && data?.campaigns.length === 0 && (
<div className="text-center py-12">
<Mail className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No campaigns found</h3>
<p className="text-neutral-500">
{statusFilter !== 'ALL' ? 'Try adjusting your filter' : 'Create your first campaign to get started'}
</p>
</div>
)}
{data?.campaigns.map(campaign => (
<Card
key={campaign.id}
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all"
onClick={() => handleCampaignClick(campaign)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="text-base truncate">{campaign.name}</CardTitle>
{getStatusBadge(campaign.status)}
</div>
{campaign.description && (
<CardDescription className="text-xs line-clamp-1">{campaign.description}</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<Mail className="h-3.5 w-3.5 text-neutral-500" />
<span className="text-neutral-500">Subject:</span>
<span className="font-medium text-neutral-900 truncate">{campaign.subject}</span>
</div>
<div className="flex items-center gap-2">
<Users className="h-3.5 w-3.5 text-neutral-500" />
<span className="text-neutral-500">Audience:</span>
<span className="text-neutral-700">{getAudienceLabel(campaign)}</span>
</div>
{campaign.scheduledFor && (
<div className="flex items-center gap-2">
<Calendar className="h-3.5 w-3.5 text-neutral-500" />
<span className="text-neutral-500">Scheduled:</span>
<span className="text-neutral-700">
{new Date(campaign.scheduledFor).toLocaleDateString()}
</span>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between pt-3 border-t">
<p className="text-sm text-neutral-500">
Page {page} of {data.totalPages}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => p + 1)}
disabled={page === data.totalPages}
>
Next
</Button>
</div>
</div>
)}
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto space-y-6 pr-2">
{/* Campaign Preview */}
{selectedCampaign && (
<Card className="bg-neutral-50">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="text-base">{selectedCampaign.name}</CardTitle>
{getStatusBadge(selectedCampaign.status)}
</div>
{selectedCampaign.description && (
<CardDescription className="text-xs">{selectedCampaign.description}</CardDescription>
)}
</div>
</div>
</CardHeader>
</Card>
)}
{/* Field Selection */}
<div className="space-y-3">
<div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('subject')}
>
<Checkbox
id="subject"
checked={selectedFields.subject}
onCheckedChange={() => toggleField('subject')}
/>
<div className="flex-1">
<Label htmlFor="subject" className="text-sm font-medium cursor-pointer">
Email Subject
</Label>
{selectedCampaign?.subject && (
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.subject}</p>
)}
</div>
</div>
<div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('body')}
>
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
<div className="flex-1">
<Label htmlFor="body" className="text-sm font-medium cursor-pointer">
Email Body
</Label>
<p className="text-xs text-neutral-500 mt-0.5">The full email content and design</p>
</div>
</div>
<div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('from')}
>
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
<div className="flex-1">
<Label htmlFor="from" className="text-sm font-medium cursor-pointer">
From Email
</Label>
{selectedCampaign?.from && (
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.from}</p>
)}
</div>
</div>
<div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('fromName')}
>
<Checkbox
id="fromName"
checked={selectedFields.fromName}
onCheckedChange={() => toggleField('fromName')}
/>
<div className="flex-1">
<Label htmlFor="fromName" className="text-sm font-medium cursor-pointer">
From Name
</Label>
{selectedCampaign?.fromName && (
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.fromName}</p>
)}
</div>
</div>
<div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('replyTo')}
>
<Checkbox
id="replyTo"
checked={selectedFields.replyTo}
onCheckedChange={() => toggleField('replyTo')}
/>
<div className="flex-1">
<Label htmlFor="replyTo" className="text-sm font-medium cursor-pointer">
Reply-To Email
</Label>
{selectedCampaign?.replyTo && (
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.replyTo}</p>
)}
</div>
</div>
<div
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('audience')}
>
<Checkbox
id="audience"
checked={selectedFields.audience}
onCheckedChange={() => toggleField('audience')}
/>
<div className="flex-1">
<Label htmlFor="audience" className="text-sm font-medium cursor-pointer">
Audience Settings
</Label>
{selectedCampaign && (
<p className="text-xs text-neutral-500 mt-0.5">{getAudienceLabel(selectedCampaign)}</p>
)}
</div>
</div>
</div>
</div>
{/* Fixed Action Buttons */}
<div className="flex gap-3 pt-4 border-t mt-4">
<Button variant="outline" onClick={handleBack} className="flex-1">
Back
</Button>
<Button
onClick={handleConfirm}
className="flex-1"
disabled={!Object.values(selectedFields).some(v => v)}
>
Create Campaign
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
+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">
{project.name.charAt(0).toUpperCase()}
</div>
<span className="text-neutral-900">{project.name}</span>
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
{activeProject?.id === project.id && (
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
)}
@@ -5,10 +5,12 @@ import {TextAlign} from '@tiptap/extension-text-align';
import {Color} from '@tiptap/extension-color';
import {TextStyle} from '@tiptap/extension-text-style';
import {Link} from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import {Variable} from './VariableExtension';
import {setAvailableVariables, VariableMention} from './VariableMention';
import {Toolbar} from './Toolbar';
import {ResizableImage} from './ResizableImage';
import {HtmlEditor} from './HtmlEditor';
import {useContactFields, useContacts} from '../../lib/hooks/useContacts';
import {useConfig} from '../../lib/hooks/useConfig';
import {useEffect, useRef, useState} from 'react';
@@ -25,7 +27,7 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from '@plunk/ui';
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
import {network} from '../../lib/network';
@@ -153,6 +155,9 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
ResizableImage,
Variable,
VariableMention,
Placeholder.configure({
placeholder: placeholder || 'Your next email starts here!',
}),
],
// Only initialize with content if starting in visual mode
// If starting in HTML mode (due to custom HTML), keep editor empty
@@ -552,12 +557,11 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
</>
) : (
<>
<div className="min-h-[400px] max-h-[600px] overflow-y-auto">
<textarea
<div className="min-h-[400px] max-h-[600px] overflow-hidden">
<HtmlEditor
value={htmlContent}
onChange={e => handleHtmlChange(e.target.value)}
placeholder={placeholder || '<h1>Welcome!</h1><p>Your email content here...</p>'}
className="w-full h-full px-4 py-3 text-sm font-mono resize-none min-h-[400px] focus:outline-none"
onChange={handleHtmlChange}
placeholder={placeholder || 'Your next email starts here!'}
/>
</div>
{selectedContactId && (
@@ -0,0 +1,103 @@
import CodeMirror from '@uiw/react-codemirror';
import {html} from '@codemirror/lang-html';
import {EditorView} from '@codemirror/view';
import {useMemo} from 'react';
interface HtmlEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function HtmlEditor({value, onChange, placeholder}: HtmlEditorProps) {
// Custom theme configuration for better integration with the app
const theme = EditorView.theme({
'&': {
fontSize: '14px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
height: '100%',
},
'.cm-content': {
minHeight: '400px',
padding: '12px 16px',
caretColor: '#1f2937',
},
'.cm-line': {
padding: '0',
lineHeight: '1.6',
},
'.cm-gutters': {
backgroundColor: '#f9fafb',
color: '#9ca3af',
border: 'none',
borderRight: '1px solid #e5e7eb',
paddingRight: '8px',
paddingLeft: '8px',
},
'.cm-activeLineGutter': {
backgroundColor: '#f3f4f6',
color: '#374151',
},
'.cm-activeLine': {
backgroundColor: '#f9fafb',
},
'.cm-selectionBackground, ::selection': {
backgroundColor: '#dbeafe !important',
},
'.cm-focused .cm-selectionBackground, .cm-focused ::selection': {
backgroundColor: '#bfdbfe !important',
},
'.cm-cursor': {
borderLeftColor: '#1f2937',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-scroller': {
overflow: 'auto',
maxHeight: '600px',
},
});
// Extensions with HTML support and line numbers
const extensions = useMemo(
() => [
html(),
theme,
EditorView.lineWrapping, // Enable line wrapping for long lines
],
[theme]
);
return (
<div className="html-editor-container">
<CodeMirror
value={value}
height="100%"
extensions={extensions}
onChange={onChange}
placeholder={placeholder}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightActiveLine: true,
foldGutter: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
rectangularSelection: true,
crosshairCursor: true,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
searchKeymap: true,
foldKeymap: true,
completionKeymap: true,
lintKeymap: true,
}}
/>
</div>
);
}
@@ -213,7 +213,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
}
return (
<div className="border-b border-neutral-200 bg-neutral-50 p-2 flex flex-wrap gap-1 sticky top-0 z-10">
<div className="border-b border-neutral-200 bg-neutral-50 p-2 flex flex-wrap gap-1 sticky top-0 z-40">
{/* History */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
@@ -424,7 +424,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
</Button>
{showColorPicker && (
<div
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-20 max-h-96 overflow-y-auto"
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-50 max-h-96 overflow-y-auto"
style={{width: '280px'}}
>
{/* Custom color input */}
@@ -508,7 +508,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
<Link className="h-4 w-4" />
</Button>
{showLinkInput && (
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-20 min-w-max">
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-50 min-w-max">
<div className="flex gap-2 mb-2">
<input
type="url"
+7 -2
View File
@@ -28,10 +28,15 @@ export function EmailSettings({
onReplyToChange,
fromPlaceholder = 'hello',
fromNamePlaceholder = 'Your Company',
replyToPlaceholder = 'support',
replyToPlaceholder,
showFromNameHelpText = false,
layout = 'grid',
}: EmailSettingsProps) {
// Use from email's local part as the reply-to placeholder if not provided
// Extract local part (before @) since the domain is shown in a dropdown
// Fall back to fromPlaceholder when from is empty (since reply-to defaults to from)
const fromLocalPart = from.includes('@') ? from.split('@')[0] : from;
const effectiveReplyToPlaceholder = replyToPlaceholder || fromLocalPart || fromPlaceholder;
const GridWrapper = layout === 'grid' ? 'div' : 'div';
const gridClassName = layout === 'grid' ? 'grid gap-4 md:grid-cols-2' : 'space-y-4';
@@ -73,7 +78,7 @@ export function EmailSettings({
label="Reply-To Email"
value={replyTo}
onChange={onReplyToChange}
placeholder={replyToPlaceholder}
placeholder={effectiveReplyToPlaceholder}
/>
</div>
</GridWrapper>
@@ -0,0 +1,267 @@
import {
Alert,
AlertDescription,
AlertTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Progress,
} from '@plunk/ui';
import {AlertCircle, AlertTriangle, CheckCircle, Shield} from 'lucide-react';
import type {ProjectSecurityMetrics} from '@plunk/types';
interface SecuritySettingsProps {
metrics: ProjectSecurityMetrics;
isLoading: boolean;
}
export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Security Overview</CardTitle>
<CardDescription>Monitor your project&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
*/
import dayjs from 'dayjs';
/**
* Get the user's timezone
*/
@@ -106,3 +108,21 @@ export function formatUTCDateTime(date: Date): string {
timeZone: 'UTC',
});
}
/**
* Format a date as relative time with short format for very recent updates
* Returns "just now" for updates within the last minute to avoid text wrapping
*/
export function formatRelativeTime(date: Date | string): string {
const now = dayjs();
const then = dayjs(date);
const secondsAgo = now.diff(then, 'second');
// If less than 60 seconds, show "just now"
if (secondsAgo < 60) {
return 'just now';
}
// Otherwise use standard relative time
return then.fromNow();
}
@@ -0,0 +1,28 @@
import useSWR from 'swr';
import type {ProjectSecurityMetrics} from '@plunk/types';
export interface ProjectSecurityResponse {
success: boolean;
data: ProjectSecurityMetrics;
}
/**
* Hook to fetch project security metrics
* Auto-refreshes every 2 minutes to keep data current
*/
export function useProjectSecurity(projectId: string | undefined) {
const {data, error, isLoading, mutate} = useSWR<ProjectSecurityResponse>(
projectId ? `/projects/${projectId}/security` : null,
{
refreshInterval: 120000, // 2 minutes
revalidateOnFocus: false,
},
);
return {
securityMetrics: data?.data,
isLoading,
error,
mutate,
};
}
+14 -3
View File
@@ -10,9 +10,16 @@ import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
import {useProjects} from '../lib/hooks/useProject';
import {useUser} from '../lib/hooks/useUser';
import {network} from '../lib/network';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import advancedFormat from 'dayjs/plugin/advancedFormat';
// Configure dayjs plugins globally
dayjs.extend(relativeTime);
dayjs.extend(advancedFormat);
// Routes that don't require authentication
const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset'];
const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset', '/unsubscribe', '/subscribe', '/manage'];
// Routes that don't require a project
const NO_PROJECT_ROUTES = ['/projects/create'];
@@ -28,7 +35,9 @@ function App({Component, pageProps}: AppProps) {
function AuthGuard({children}: {children: React.ReactNode}) {
const {data: user, isLoading} = useUser();
const router = useRouter();
const isPublicRoute = PUBLIC_ROUTES.includes(router.pathname);
const isPublicRoute = PUBLIC_ROUTES.some(route =>
router.pathname === route || router.pathname.startsWith(`${route}/`)
);
useEffect(() => {
// If not loading, no user, and trying to access a protected route, redirect to login
@@ -102,7 +111,9 @@ export default function WithProviders(props: AppProps) {
function Root(props: AppProps) {
const router = useRouter();
const isPublicRoute = PUBLIC_ROUTES.includes(router.pathname);
const isPublicRoute = PUBLIC_ROUTES.some(route =>
router.pathname === route || router.pathname.startsWith(`${route}/`)
);
return (
<>
+78 -51
View File
@@ -41,6 +41,7 @@ import {
ArrowLeft,
Calendar,
ChevronDown,
Info,
Mail,
MousePointer,
Save,
@@ -86,7 +87,7 @@ export default function CampaignDetailsPage() {
id && campaign?.data.status !== CampaignStatus.DRAFT ? `/campaigns/${id}/stats` : null,
{
revalidateOnFocus: false,
refreshInterval: campaign?.data.status === CampaignStatus.SENDING ? 5000 : 0, // Refresh every 5s if sending
refreshInterval: campaign?.data.status === CampaignStatus.SENDING ? 15000 : 0, // Refresh every 15s while sending
},
);
@@ -97,7 +98,7 @@ export default function CampaignDetailsPage() {
// Fetch project members for test email
const {data: projectMembers} = useSWR<{data: Array<{userId: string; email: string; role: string}>}>(
id ? `/projects/${campaign?.data.projectId}/members` : null,
id && campaign?.data.projectId ? `/projects/${campaign.data.projectId}/members` : null,
{revalidateOnFocus: false},
);
@@ -213,14 +214,28 @@ export default function CampaignDetailsPage() {
subject: editedCampaign.subject,
body: editedCampaign.body,
from: editedCampaign.from,
fromName: editedCampaign.fromName || undefined,
replyTo: editedCampaign.replyTo || undefined,
fromName: editedCampaign.fromName || null,
replyTo: editedCampaign.replyTo || null,
audienceType: editedCampaign.audienceType,
segmentId: editedCampaign.segmentId || undefined,
});
// Silent save - no toast notification
setHasChanges(false);
void mutate();
// Refetch and re-sync the edited campaign with fresh data
const updated = await mutate();
if (updated?.data) {
setEditedCampaign({
name: updated.data.name,
description: updated.data.description || '',
subject: updated.data.subject,
body: updated.data.body,
from: updated.data.from,
fromName: updated.data.fromName || '',
replyTo: updated.data.replyTo || '',
audienceType: updated.data.audienceType,
segmentId: updated.data.segmentId || undefined,
});
}
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update campaign');
} finally {
@@ -312,19 +327,9 @@ export default function CampaignDetailsPage() {
const c = campaign.data;
const s = stats?.data;
// Get recipient count for draft campaigns
const getDraftRecipientCount = () => {
if (!campaign?.data) return 0;
const c = campaign.data;
if (c.audienceType === CampaignAudienceType.SEGMENT && c.segmentId && segments) {
const segment = segments.find(s => s.id === c.segmentId);
return segment?.memberCount || 0;
}
return 0; // We'd need total contact count for ALL audience type
};
const draftRecipientCount = isEditMode ? getDraftRecipientCount() : 0;
// Get recipient count for draft campaigns from the campaign's totalRecipients field
// The backend calculates this for all audience types when the campaign is created/updated
const draftRecipientCount = isEditMode && campaign?.data ? campaign.data.totalRecipients : 0;
// Render edit form for drafts
if (isEditMode) {
@@ -386,18 +391,37 @@ export default function CampaignDetailsPage() {
<ChevronDown className="h-4 w-4 sm:ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsTestEmailDialogOpen(true)}>
<TestTube className="h-4 w-4 mr-2" />
Send Test Email
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuItem onClick={() => setIsTestEmailDialogOpen(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<TestTube className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Send Test Email</span>
<span className="text-xs text-neutral-500 leading-snug">
Preview in your inbox before sending
</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowSendDialog(true)}>
<Send className="h-4 w-4 mr-2" />
Send Now
<DropdownMenuItem onClick={() => setShowSendDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<Send className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Send Now</span>
<span className="text-xs text-neutral-500 leading-snug">
Send immediately to all recipients
</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsScheduleDialogOpen(true)}>
<Calendar className="h-4 w-4 mr-2" />
Schedule for Later
<DropdownMenuItem onClick={() => setIsScheduleDialogOpen(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<Calendar className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Schedule for Later</span>
<span className="text-xs text-neutral-500 leading-snug">Choose a specific date and time</span>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -497,11 +521,6 @@ export default function CampaignDetailsPage() {
title="Segment"
description="Target a defined group of contacts"
/>
<SelectItemWithDescription
value={CampaignAudienceType.FILTERED}
title="Filtered"
description="Use advanced filter conditions"
/>
</SelectContent>
</Select>
</div>
@@ -551,13 +570,20 @@ export default function CampaignDetailsPage() {
{/* Show recipient count */}
{draftRecipientCount > 0 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg space-y-2">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-900">
{draftRecipientCount.toLocaleString()} recipients
</span>
</div>
<div className="flex items-start gap-2">
<Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-800">
This count will be recalculated right before sending to ensure accuracy. The final number may
differ if contacts subscribe, unsubscribe, or segment membership changes.
</p>
</div>
</div>
)}
</CardContent>
@@ -565,7 +591,7 @@ export default function CampaignDetailsPage() {
</div>
{/* Email Editor - Full Width */}
<Card>
<Card className="overflow-visible">
<CardHeader>
<CardTitle>Email Content</CardTitle>
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
@@ -577,7 +603,6 @@ export default function CampaignDetailsPage() {
setEditedCampaign({...editedCampaign, body});
setHasChanges(true);
}}
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
/>
</CardContent>
</Card>
@@ -972,13 +997,21 @@ export default function CampaignDetailsPage() {
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Scheduled For</p>
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-neutral-400 mt-0.5" />
<div>
<p className="text-sm font-medium text-neutral-900">
{formatFullDateTime(new Date(c.scheduledFor))}
</p>
<p className="text-xs text-neutral-500 mt-1">
UTC: {formatUTCDateTime(new Date(c.scheduledFor))}
</p>
<div className="space-y-2">
<div>
<p className="text-sm font-medium text-neutral-900">
{formatFullDateTime(new Date(c.scheduledFor))}
</p>
<p className="text-xs text-neutral-500 mt-1">
UTC: {formatUTCDateTime(new Date(c.scheduledFor))}
</p>
</div>
{c.status === CampaignStatus.SCHEDULED && (
<div className="flex items-start gap-1.5 p-2 bg-blue-50 border border-blue-200 rounded">
<Info className="h-3 w-3 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-800">Recipient count will be recalculated at send time</p>
</div>
)}
</div>
</div>
</div>
@@ -987,21 +1020,15 @@ export default function CampaignDetailsPage() {
{/* Sent At */}
{c.sentAt && (
<div className="pb-3 border-b border-neutral-100">
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent At</p>
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent On</p>
<div className="flex items-center gap-2">
<Send className="h-4 w-4 text-neutral-400" />
<p className="text-sm font-medium text-neutral-900">
{new Date(c.sentAt).toLocaleDateString()} at {new Date(c.sentAt).toLocaleTimeString()}
{formatFullDateTime(new Date(c.sentAt))}
</p>
</div>
</div>
)}
{/* Created */}
<div>
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Created</p>
<p className="text-sm text-neutral-900">{new Date(c.createdAt).toLocaleDateString()}</p>
</div>
</CardContent>
</Card>
</div>
+127 -9
View File
@@ -25,7 +25,7 @@ import {EmailFormValidator} from '../../lib/validation';
import {ArrowLeft, Save, Users} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useState} from 'react';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
@@ -43,9 +43,92 @@ export default function CreateCampaignPage() {
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
const [segmentId, setSegmentId] = useState('');
const [saving, setSaving] = useState(false);
const [loadingTemplate, setLoadingTemplate] = useState(false);
const {data: segments} = useSWR<Segment[]>('/segments', {revalidateOnFocus: false});
// Load template or campaign data if provided in query params
useEffect(() => {
const loadData = async () => {
const {
templateId,
campaignId,
name: queryName,
subject: querySubject,
from: queryFrom,
fromName: queryFromName,
replyTo: queryReplyTo,
audienceType: queryAudienceType,
segmentId: querySegmentId,
} = router.query;
// Handle template loading
if (templateId && typeof templateId === 'string') {
setLoadingTemplate(true);
try {
// Fetch the full template to get the body content
const template = await network.fetch<{data: {body: string}}>('GET', `/templates/${templateId}`);
// Pre-fill form with template data
if (queryName && typeof queryName === 'string') setName(queryName);
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
setBody(template.data.body);
toast.success('Template loaded successfully');
} catch {
toast.error('Failed to load template');
} finally {
setLoadingTemplate(false);
}
}
// Handle campaign loading
else if (campaignId && typeof campaignId === 'string') {
setLoadingTemplate(true);
try {
// Fetch the full campaign to get the body content
const campaign = await network.fetch<{data: {body: string}}>('GET', `/campaigns/${campaignId}`);
// Pre-fill form with campaign data
if (queryName && typeof queryName === 'string') setName(queryName);
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
if (queryAudienceType && typeof queryAudienceType === 'string') {
setAudienceType(queryAudienceType as CampaignAudienceType);
}
if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId);
setBody(campaign.data.body);
toast.success('Campaign loaded successfully');
} catch {
toast.error('Failed to load campaign');
} finally {
setLoadingTemplate(false);
}
}
// Handle query params without template/campaign ID (direct field values)
else {
if (queryName && typeof queryName === 'string') setName(queryName);
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
if (queryAudienceType && typeof queryAudienceType === 'string') {
setAudienceType(queryAudienceType as CampaignAudienceType);
}
if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId);
}
};
if (router.isReady) {
void loadData();
}
}, [router.isReady, router.query]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -64,8 +147,8 @@ export default function CreateCampaignPage() {
subject,
body,
from,
fromName: fromName || undefined,
replyTo: replyTo || undefined,
fromName: fromName || null,
replyTo: replyTo || null,
audienceType,
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
@@ -94,6 +177,26 @@ export default function CreateCampaignPage() {
<>
<NextSeo title="Create Campaign" />
<DashboardLayout>
{loadingTemplate && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900 mb-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="text-sm text-neutral-700">Loading template...</p>
</div>
</div>
)}
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3 sm:gap-4">
@@ -188,7 +291,7 @@ export default function CreateCampaignPage() {
</Card>
{/* Email Content */}
<Card>
<Card className="overflow-visible">
<CardHeader>
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" />
</CardHeader>
@@ -197,11 +300,7 @@ export default function CreateCampaignPage() {
<Label htmlFor="body">
Email Body <span className="text-red-500">*</span>
</Label>
<EmailEditor
value={body}
onChange={setBody}
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
/>
<EmailEditor value={body} onChange={setBody} />
</div>
</CardContent>
</Card>
@@ -283,6 +382,18 @@ export default function CreateCampaignPage() {
</div>
</div>
)}
{audienceType === CampaignAudienceType.ALL && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-900">All subscribed contacts</p>
<p className="text-xs text-blue-700 mt-1">
This campaign will be sent to all contacts who haven&#39;t unsubscribed
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
@@ -339,6 +450,13 @@ export default function CreateCampaignPage() {
<span className="font-medium">{estimatedRecipients.toLocaleString()}</span>
</div>
)}
{audienceType === CampaignAudienceType.ALL && (
<div className="flex justify-between py-2">
<span className="text-neutral-500">Recipients</span>
<span className="font-medium">All subscribed</span>
</div>
)}
</div>
{/* Info Note */}
+241 -16
View File
@@ -7,22 +7,31 @@ import {
CardHeader,
CardTitle,
ConfirmDialog,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import type {Campaign} from '@plunk/db';
import type {Campaign, Template} from '@plunk/db';
import {CampaignStatus} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog';
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
import {network} from '../../lib/network';
import {Calendar, Copy, Mail, Plus, Trash2, Users} from 'lucide-react';
import {formatRelativeTime} from '../../lib/dateUtils';
import {Calendar, ChevronDown, Copy, FileText, Info, Mail, Plus, RefreshCw, Trash2, Users} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
interface PaginatedCampaigns {
campaigns: Campaign[];
@@ -33,12 +42,15 @@ interface PaginatedCampaigns {
}
export default function CampaignsPage() {
const router = useRouter();
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>('ALL');
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [campaignToDelete, setCampaignToDelete] = useState<string | null>(null);
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
const {data, mutate, isLoading} = useSWR<PaginatedCampaigns>(
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
@@ -103,6 +115,93 @@ export default function CampaignsPage() {
}
};
const handleSelectTemplate = (
template: Template,
selectedFields: {
subject: boolean;
body: boolean;
from: boolean;
fromName: boolean;
replyTo: boolean;
},
) => {
// Navigate to create page with template data as query params
const query: Record<string, string> = {
name: `${template.name}`,
};
// Only include templateId if body is selected (needed to fetch body content)
if (selectedFields.body) {
query.templateId = template.id;
}
// Add selected fields to query params
if (selectedFields.subject) {
query.subject = template.subject;
}
if (selectedFields.from) {
query.from = template.from;
}
if (selectedFields.fromName && template.fromName) {
query.fromName = template.fromName;
}
if (selectedFields.replyTo && template.replyTo) {
query.replyTo = template.replyTo;
}
void router.push({
pathname: '/campaigns/create',
query,
});
};
const handleSelectCampaign = (
campaign: Campaign,
selectedFields: {
subject: boolean;
body: boolean;
from: boolean;
fromName: boolean;
replyTo: boolean;
audience: boolean;
},
) => {
// Navigate to create page with campaign data as query params
const query: Record<string, string> = {
name: `${campaign.name}`,
};
// Only include campaignId if body is selected (needed to fetch body content)
if (selectedFields.body) {
query.campaignId = campaign.id;
}
// Add selected fields to query params
if (selectedFields.subject) {
query.subject = campaign.subject;
}
if (selectedFields.from) {
query.from = campaign.from;
}
if (selectedFields.fromName && campaign.fromName) {
query.fromName = campaign.fromName;
}
if (selectedFields.replyTo && campaign.replyTo) {
query.replyTo = campaign.replyTo;
}
if (selectedFields.audience) {
query.audienceType = campaign.audienceType;
if (campaign.segmentId) {
query.segmentId = campaign.segmentId;
}
}
void router.push({
pathname: '/campaigns/create',
query,
});
};
return (
<>
<NextSeo title="Campaigns" />
@@ -116,13 +215,51 @@ export default function CampaignsPage() {
Send one-time email broadcasts to your contacts. {data?.total ? `${data.total} total campaigns` : ''}
</p>
</div>
<Link href="/campaigns/create" className="w-full sm:w-auto">
<Button className="w-full sm:w-auto">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create Campaign</span>
<span className="sm:hidden">Create</span>
</Button>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="w-full sm:w-auto">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create Campaign</span>
<span className="sm:hidden">Create</span>
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuItem asChild className="py-3 cursor-pointer">
<Link href="/campaigns/create" className="flex items-start gap-3">
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Empty Campaign</span>
<span className="text-xs text-neutral-500 leading-snug">
Start from scratch with a blank canvas
</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">From Template</span>
<span className="text-xs text-neutral-500 leading-snug">
Use an existing template as a starting point
</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">From Previous Campaign</span>
<span className="text-xs text-neutral-500 leading-snug">
Copy content and settings from an existing campaign
</span>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Filters */}
@@ -171,12 +308,60 @@ export default function CampaignsPage() {
? 'Try adjusting your filters or create a new campaign.'
: 'Create your first campaign to send emails to your contacts.'}
</p>
<Link href="/campaigns/create">
<Button size="lg">
<Plus className="h-4 w-4" />
Create Your First Campaign
</Button>
</Link>
{statusFilter === 'ALL' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="lg">
<Plus className="h-4 w-4" />
Create Your First Campaign
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-80">
<DropdownMenuItem asChild className="py-3 cursor-pointer">
<Link href="/campaigns/create" className="flex items-start gap-3">
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">Empty Campaign</span>
<span className="text-xs text-neutral-500 leading-snug">
Start from scratch with a blank canvas
</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">From Template</span>
<span className="text-xs text-neutral-500 leading-snug">
Use an existing template as a starting point
</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium text-sm">From Previous Campaign</span>
<span className="text-xs text-neutral-500 leading-snug">
Copy content and settings from an existing campaign
</span>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{statusFilter !== 'ALL' && (
<Link href="/campaigns/create">
<Button size="lg">
<Plus className="h-4 w-4" />
Create Campaign
</Button>
</Link>
)}
</div>
</CardContent>
</Card>
@@ -216,11 +401,20 @@ export default function CampaignsPage() {
<div className="flex items-center gap-2 mb-1">
<Users className="h-3.5 w-3.5 text-blue-600" />
<span className="text-xs font-medium text-blue-900">Recipients</span>
{(campaign.status === 'DRAFT' || campaign.status === 'SCHEDULED') && (
<div className="group relative">
<Info className="h-3 w-3 text-blue-600 cursor-help" />
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1">
This count will be recalculated before sending
</div>
</div>
)}
</div>
<p className="text-lg font-bold text-blue-900">{campaign.totalRecipients.toLocaleString()}</p>
{campaign.totalRecipients > 0 && (
{campaign.totalRecipients > 0 && campaign.status !== 'DRAFT' && (
<p className="text-xs text-blue-700 mt-1">{deliveryProgress.toFixed(0)}% sent</p>
)}
{campaign.status === 'DRAFT' && <p className="text-xs text-blue-600 mt-1">Estimated</p>}
</div>
{/* Open Rate */}
@@ -273,6 +467,25 @@ export default function CampaignsPage() {
)}
</div>
{/* Metadata */}
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {formatRelativeTime(campaign.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(campaign.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="group relative inline-block cursor-help">
<span> Updated {formatRelativeTime(campaign.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(campaign.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-2 border-t border-neutral-100">
<Link href={`/campaigns/${campaign.id}`} className="flex-1">
@@ -356,6 +569,18 @@ export default function CampaignsPage() {
confirmText="Delete Campaign"
variant="destructive"
/>
<TemplateSelectionDialog
open={showTemplateDialog}
onOpenChange={setShowTemplateDialog}
onSelectTemplate={handleSelectTemplate}
/>
<CampaignSelectionDialog
open={showCampaignDialog}
onOpenChange={setShowCampaignDialog}
onSelectCampaign={handleSelectCampaign}
/>
</DashboardLayout>
</>
);
+23 -30
View File
@@ -7,20 +7,21 @@ import {
CardTitle,
ConfirmDialog,
Input,
Label,
Label
} from '@plunk/ui';
import type {Contact} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {KeyValueEditor} from '../../components/KeyValueEditor';
import {ActivityFeed} from '../../components/ActivityFeed';
import {network} from '../../lib/network';
import {ArrowLeft, Calendar, Copy, Database, ExternalLink, Mail, Save, Settings, Trash2} from 'lucide-react';
import {ArrowLeft, Copy, Database, ExternalLink, Mail, Save, Settings, Trash2} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {ContactSchemas} from '@plunk/shared';
import dayjs from 'dayjs';
export default function ContactDetailPage() {
const router = useRouter();
@@ -250,34 +251,6 @@ export default function ContactDetailPage() {
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Created</p>
<p className="text-sm text-neutral-500">
{new Date(contact.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<p className="text-sm text-neutral-500">
{new Date(contact.updatedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1 min-w-0">
@@ -285,6 +258,26 @@ export default function ContactDetailPage() {
<p className="text-xs text-neutral-500 font-mono break-all">{contact.id}</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Created</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(contact.createdAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(contact.updatedAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</CardContent>
</Card>
+16 -6
View File
@@ -13,12 +13,13 @@ import {
DialogTitle,
Input,
Label,
Switch,
Switch
} from '@plunk/ui';
import type {Contact} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {KeyValueEditor} from '../../components/KeyValueEditor';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {
CheckCircle,
ChevronLeft,
@@ -32,7 +33,7 @@ import {
Search,
Trash2,
Upload,
XCircle,
XCircle
} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
@@ -40,6 +41,7 @@ import {useEffect, useRef, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {ContactSchemas} from '@plunk/shared';
import dayjs from 'dayjs';
interface PaginatedContacts {
contacts: Contact[];
@@ -279,7 +281,12 @@ export default function ContactsPage() {
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
{new Date(contact.createdAt).toLocaleDateString()}
<div className="group relative inline-block cursor-help">
{formatRelativeTime(contact.createdAt)}
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1 whitespace-nowrap">
{dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
@@ -324,9 +331,12 @@ export default function ContactsPage() {
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-neutral-500">
{new Date(contact.createdAt).toLocaleDateString()}
</span>
<div className="group relative inline-block cursor-help">
<span className="text-xs text-neutral-500">{formatRelativeTime(contact.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.createdAt).format('Do MMMM YYYY, h:mm A')}
</div>
</div>
<div className="flex items-center gap-1">
<Link href={`/contacts/${contact.id}`}>
<Button variant="ghost" size="sm">
+105 -87
View File
@@ -7,7 +7,7 @@ import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardTitle
} from '@plunk/ui';
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
import {NextSeo} from 'next-seo';
@@ -15,14 +15,19 @@ import Link from 'next/link';
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
import {DashboardLayout} from '../components/DashboardLayout';
import {QuickStart} from '../components/QuickStart';
import {SecurityWarningBanner} from '../components/SecurityWarningBanner';
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
import {useProjectSecurity} from '../lib/hooks/useProjectSecurity';
import {useConfig} from '../lib/hooks/useConfig';
export default function Index() {
const {activeProject} = useActiveProject();
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id);
const {securityMetrics} = useProjectSecurity(activeProject?.id);
const {data: config} = useConfig();
const stats = [
{
@@ -52,95 +57,108 @@ export default function Index() {
<NextSeo title="Dashboard" />
<DashboardLayout>
<div className="space-y-8">
{/* Project Disabled Banner */}
{activeProject && activeProject.disabled && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Project Disabled - Read-Only Mode</AlertTitle>
<AlertDescription>
This project has been disabled due to security violations (high bounce or complaint rates). All scheduled
campaigns and workflows have been cancelled. The project is now in read-only mode - you can view your data
but cannot create, update, or delete anything. Please contact support to resolve this issue.
</AlertDescription>
</Alert>
)}
{/* Project Disabled Banner */}
{activeProject && activeProject.disabled && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Project Disabled - Read-Only Mode</AlertTitle>
<AlertDescription>
This project has been disabled due to security violations (high bounce or complaint rates). All
scheduled campaigns and workflows have been cancelled. The project is now in read-only mode - you can
view your data but cannot create, update, or delete anything. Please contact support to resolve this
issue.
</AlertDescription>
</Alert>
)}
{/* Subscription Warning Banner */}
{activeProject && !activeProject.disabled && !activeProject.subscription && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Upgrade to remove Plunk branding</AlertTitle>
<AlertDescription className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<span className="text-sm">Your emails currently include Plunk branding. Upgrade to a subscription to remove it.</span>
<Link href="/settings?tab=billing">
<Button size="sm" className="w-full sm:w-auto">Upgrade Now</Button>
</Link>
</AlertDescription>
</Alert>
)}
{/* Security Warning Banner */}
{activeProject && !activeProject.disabled && securityMetrics && (
<SecurityWarningBanner status={securityMetrics.status} />
)}
{/* Header */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
Welcome back to {activeProject?.name || 'Plunk'}. Here&apos;s what&apos;s happening with your emails.
</p>
{/* Subscription Warning Banner */}
{activeProject &&
!activeProject.disabled &&
!activeProject.subscription &&
config?.features.billing.enabled && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Upgrade to remove Plunk branding</AlertTitle>
<AlertDescription className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<span className="text-sm">
Your emails currently include Plunk branding. Upgrade to a subscription to remove it.
</span>
<Link href="/settings?tab=billing">
<Button size="sm" className="w-full sm:w-auto">
Upgrade Now
</Button>
</Link>
</AlertDescription>
</Alert>
)}
{/* Header */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
Welcome back to {activeProject?.name || 'Plunk'}. Here&apos;s what&apos;s happening with your emails.
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map(stat => {
const Icon = stat.icon;
return (
<Card key={stat.name}>
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>{stat.name}</CardDescription>
<Icon className="h-4 w-4 text-neutral-500" />
</div>
<CardTitle className="text-2xl">{stat.value}</CardTitle>
</CardHeader>
</Card>
);
})}
</div>
{/* Quick Actions & API Keys */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Quick Start */}
<QuickStart setupState={setupState} isLoading={isLoadingSetupState} />
{/* API Keys */}
<Card>
<CardHeader>
<CardTitle>API Keys</CardTitle>
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{activeProject ? (
<>
<ApiKeyDisplay
label="Public Key"
value={activeProject.public}
description="Use this key for client-side integrations"
/>
<ApiKeyDisplay
label="Secret Key"
value={activeProject.secret}
description="Keep this key secure and never expose it publicly"
isSecret
/>
</>
) : (
<p className="text-sm text-neutral-500">No project selected</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map(stat => {
const Icon = stat.icon;
return (
<Card key={stat.name}>
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>{stat.name}</CardDescription>
<Icon className="h-4 w-4 text-neutral-500" />
</div>
<CardTitle className="text-2xl">{stat.value}</CardTitle>
</CardHeader>
</Card>
);
})}
</div>
{/* Quick Actions & API Keys */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Quick Start */}
<QuickStart setupState={setupState} isLoading={isLoadingSetupState} />
{/* API Keys */}
<Card>
<CardHeader>
<CardTitle>API Keys</CardTitle>
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{activeProject ? (
<>
<ApiKeyDisplay
label="Public Key"
value={activeProject.public}
description="Use this key for client-side integrations"
/>
<ApiKeyDisplay
label="Secret Key"
value={activeProject.secret}
description="Keep this key secure and never expose it publicly"
isSecret
/>
</>
) : (
<p className="text-sm text-neutral-500">No project selected</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</DashboardLayout>
</DashboardLayout>
</>
);
}
+22 -17
View File
@@ -12,7 +12,7 @@ import {
import type {Contact, Segment} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {ArrowLeft, Calendar, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
@@ -21,6 +21,7 @@ import useSWR from 'swr';
import type {FilterCondition} from '@plunk/types';
import {SegmentSchemas} from '@plunk/shared';
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
import dayjs from 'dayjs';
interface PaginatedContacts {
contacts: Contact[];
@@ -388,22 +389,6 @@ export default function SegmentDetailPage() {
<CardTitle>Metadata</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Created</p>
<p className="text-sm text-neutral-500">{new Date(segment.createdAt).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<p className="text-sm text-neutral-500">{new Date(segment.updatedAt).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1 min-w-0">
@@ -411,6 +396,26 @@ export default function SegmentDetailPage() {
<p className="text-xs text-neutral-500 font-mono break-all">{segment.id}</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Created</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(segment.createdAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(segment.updatedAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
+19 -3
View File
@@ -13,12 +13,14 @@ import type {Segment} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {AlertTriangle, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
import {formatRelativeTime} from '../../lib/dateUtils';
import {AlertTriangle, Calendar, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
// Helper function to count total filters in a condition
function countFiltersInCondition(condition: unknown): number {
@@ -194,8 +196,22 @@ export default function SegmentsPage() {
</div>
{/* Metadata */}
<div className="text-xs text-neutral-500 pt-2 border-t border-neutral-200">
Created {new Date(segment.createdAt).toLocaleDateString()}
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {formatRelativeTime(segment.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="group relative inline-block cursor-help">
<span> Updated {formatRelativeTime(segment.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</div>
</CardContent>
+38 -5
View File
@@ -37,7 +37,16 @@ import {
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import {AlertTriangle, CreditCard, Database, Globe, Mail, Settings as SettingsIcon, Users} from 'lucide-react';
import {
AlertTriangle,
CreditCard,
Database,
Globe,
Mail,
Settings as SettingsIcon,
Shield,
Users,
} from 'lucide-react';
import type {z} from 'zod';
import {useRouter} from 'next/router';
import {DashboardLayout} from '../../components/DashboardLayout';
@@ -50,14 +59,16 @@ import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
import {SmtpSettings} from '../../components/SmtpSettings';
import {DataManagementSettings} from '../../components/DataManagementSettings';
import {TeamSettings} from '../../components/TeamSettings';
import {SecuritySettings} from '../../components/SecuritySettings';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
import {network} from '../../lib/network';
import {useProjects} from '../../lib/hooks/useProject';
import {useConfig} from '../../lib/hooks/useConfig';
import {useUser} from '../../lib/hooks/useUser';
import {useProjectSecurity} from '../../lib/hooks/useProjectSecurity';
import useSWR from 'swr';
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team';
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team' | 'security';
interface Tab {
id: TabId;
@@ -71,6 +82,7 @@ const buildTabs = (options: {billingEnabled: boolean; smtpEnabled: boolean}): Ta
const allTabs: Tab[] = [
{id: 'general', label: 'General', icon: SettingsIcon},
{id: 'team', label: 'Team', icon: Users},
{id: 'security', label: 'Security', icon: Shield},
{id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled},
{id: 'domains', label: 'Domains', icon: Globe},
{id: 'smtp', label: 'SMTP', icon: Mail, condition: smtpEnabled},
@@ -103,6 +115,8 @@ export default function Settings() {
const currentUserMembership = membershipData?.data.find(m => m.userId === user?.id);
const currentUserRole = currentUserMembership?.role || 'MEMBER';
const {securityMetrics, isLoading: isLoadingSecurityMetrics} = useProjectSecurity(activeProject?.id);
const billingEnabled = config?.features.billing.enabled ?? false;
const smtpEnabled = config?.features.smtp.enabled ?? false;
const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false;
@@ -368,9 +382,9 @@ export default function Settings() {
{buildTabs({billingEnabled, smtpEnabled}).map(tab => {
const Icon = tab.icon;
return (
<TabsTrigger key={tab.id} value={tab.id} className="flex items-center gap-2">
<TabsTrigger key={tab.id} value={tab.id} className="flex items-center gap-2" title={tab.label}>
<Icon className="h-4 w-4" />
{tab.label}
<span className="hidden md:inline">{tab.label}</span>
</TabsTrigger>
);
})}
@@ -647,7 +661,8 @@ export default function Settings() {
<span className="font-medium">No Active Subscription</span>
</div>
<p className="text-sm text-neutral-600">
Start a subscription to unlock premium features and support the development of Plunk.
Start a subscription and support the development of Plunk. You will be charged a one-time
onboarding fee which will be credited to your first invoice.
</p>
</div>
@@ -690,6 +705,24 @@ export default function Settings() {
/>
</TabsContent>
{/* Security Tab */}
<TabsContent value="security">
{securityMetrics ? (
<SecuritySettings metrics={securityMetrics} isLoading={isLoadingSecurityMetrics} />
) : (
<Card>
<CardHeader>
<CardTitle>Security Overview</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-neutral-500">
{isLoadingSecurityMetrics ? 'Loading...' : 'Unable to load security metrics'}
</p>
</CardContent>
</Card>
)}
</TabsContent>
{/* Domains Tab */}
<TabsContent value="domains">
<DomainsSettings projectId={activeProject.id} />
+3 -4
View File
@@ -93,8 +93,8 @@ export default function TemplateEditorPage() {
subject: editedTemplate.subject,
body: editedTemplate.body,
from: editedTemplate.from,
fromName: editedTemplate.fromName || undefined,
replyTo: editedTemplate.replyTo || undefined,
fromName: editedTemplate.fromName || null,
replyTo: editedTemplate.replyTo || null,
type: editedTemplate.type,
});
@@ -279,7 +279,7 @@ export default function TemplateEditorPage() {
{/* Email Body */}
<div>
<Card>
<Card className="overflow-visible">
<CardHeader>
<CardTitle>Email Body</CardTitle>
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
@@ -288,7 +288,6 @@ export default function TemplateEditorPage() {
<EmailEditor
value={editedTemplate.body || ''}
onChange={body => setEditedTemplate({...editedTemplate, body})}
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
/>
</CardContent>
</Card>
+4 -8
View File
@@ -58,8 +58,8 @@ export default function CreateTemplatePage() {
subject,
body,
from,
fromName: fromName || undefined,
replyTo: replyTo || undefined,
fromName: fromName || null,
replyTo: replyTo || null,
type,
});
@@ -179,17 +179,13 @@ export default function CreateTemplatePage() {
</Card>
{/* Email Body */}
<Card>
<Card className="overflow-visible">
<CardHeader>
<CardTitle>Email Body</CardTitle>
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor
value={body}
onChange={setBody}
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
/>
<EmailEditor value={body} onChange={setBody} />
</CardContent>
</Card>
</form>
+19 -3
View File
@@ -12,12 +12,14 @@ import {
import type {Template} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
import {formatRelativeTime} from '../../lib/dateUtils';
import {Calendar, Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
interface PaginatedTemplates {
templates: Template[];
@@ -252,8 +254,22 @@ export default function TemplatesPage() {
<p className="text-xs text-neutral-500 mb-1">From</p>
<p className="text-sm text-neutral-700">{template.from}</p>
</div>
<div className="flex items-center gap-6 text-sm text-neutral-500 pt-2 border-t border-neutral-100">
<div>Created {new Date(template.createdAt).toLocaleDateString()}</div>
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {formatRelativeTime(template.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(template.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="group relative inline-block cursor-help">
<span> Updated {formatRelativeTime(template.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(template.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
{template.replyTo && (
<div>
Reply to: <span className="text-neutral-700">{template.replyTo}</span>
+7 -1
View File
@@ -57,6 +57,7 @@ import useSWR from 'swr';
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
import {ReactFlowProvider} from '@xyflow/react';
import {WorkflowSchemas} from '@plunk/shared';
import dayjs from 'dayjs';
interface WorkflowWithDetails extends Workflow {
steps: (WorkflowStep & {
@@ -611,7 +612,12 @@ export default function WorkflowEditorPage() {
{execution.currentStep?.name ?? '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
{new Date(execution.startedAt).toLocaleString()}
<div className="group relative inline-block cursor-help">
{dayjs(execution.startedAt).fromNow()}
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1 whitespace-nowrap">
{dayjs(execution.startedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{(execution.status === 'RUNNING' || execution.status === 'WAITING') && (
+20 -2
View File
@@ -23,13 +23,15 @@ import {
import type {Workflow} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react';
import {formatRelativeTime} from '../../lib/dateUtils';
import {Calendar, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {WorkflowSchemas} from '@plunk/shared';
import dayjs from 'dayjs';
interface PaginatedWorkflows {
workflows: (Workflow & {_count?: {steps: number; executions: number}})[];
@@ -252,7 +254,23 @@ export default function WorkflowsPage() {
<span className="font-medium text-neutral-900">{workflow._count?.executions ?? 0}</span>{' '}
executions
</div>
<div>Created {new Date(workflow.createdAt).toLocaleDateString()}</div>
</div>
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {formatRelativeTime(workflow.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(workflow.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="group relative inline-block cursor-help">
<span> Updated {formatRelativeTime(workflow.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(workflow.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</CardContent>
</Card>
+3 -3
View File
@@ -21,10 +21,10 @@
"fumadocs-openapi": "^10.0.11",
"fumadocs-ui": "16.0.8",
"lucide-react": "^0.553.0",
"next": "^16.0.7",
"next": "^16.0.10",
"next-sitemap": "^4.2.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"shiki": "^3.15.0",
"unist-util-visit": "^5.0.0"
},
+7
View File
@@ -183,6 +183,13 @@ services:
# If using PEM files, SMTP_DOMAIN is optional
SMTP_DOMAIN: ${SMTP_DOMAIN:-}
# Platform emails
PLUNK_API_KEY: ${PLUNK_API_KEY:-}
PLUNK_FROM_ADDRESS: ${PLUNK_FROM_ADDRESS:-}
# Security
AUTO_PROJECT_DISABLE: ${AUTO_PROJECT_DISABLE:-false}
volumes:
# Persistent storage for application data
- plunk_data:/app/data
+1 -1
View File
@@ -1,6 +1,6 @@
services:
db:
image: postgres
image: postgres:16-alpine
ports:
- 55432:5432
environment:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plunk",
"version": "0.1.1",
"version": "0.2.0",
"private": true,
"scripts": {
"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([projectId, contactId, name, createdAt]) // For event-based segment queries (fast!)
@@index([contactId, name, createdAt]) // For per-contact event lookups
@@index([projectId, name, createdAt]) // For event stats and analytics by type over time
@@index([contactId, createdAt]) // For contact activity timeline (recent events)
@@index([data(ops: JsonbOps)], type: Gin) // GIN index for event data queries
@@map("events")
}
+4 -5
View File
@@ -14,14 +14,13 @@
"react-email": "5.0.1",
"typescript": "^5.7.2"
},
"peerDependencies": {
"react": "^19.2.1",
"react-dom": "^19.2.1"
},
"dependencies": {
"@plunk/db": "*",
"@plunk/types": "*",
"@react-email/components": "^1.0.0"
"@react-email/components": "^1.0.0",
"@react-email/tailwind": "^2.0.1",
"react": "^19.2.1",
"react-dom": "^19.2.1"
},
"exports": {
".": "./dist/index.js"
+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';
export function Header() {
return (
<>
<Img src="https://www.swyp.be/favicon/web-app-manifest-192x192.png" alt="Swyp Logo" width="40" height="40" />
</>
<Section className="border-b border-gray-100 bg-white px-8 py-8">
<Img
src="https://next.useplunk.com/assets/logo.png"
alt="Plunk"
width="40"
height="40"
className="mx-auto"
/>
</Section>
);
}
@@ -0,0 +1,115 @@
import {Heading, Link, Section, Text} from '@react-email/components';
import * as React from 'react';
import {EmailLayout} from '../common/EmailLayout';
import {Footer} from '../common/Footer';
import {Header} from '../common/Header';
interface BillingLimitExceededEmailProps {
projectName: string;
projectId: string;
usage: number;
limit: number;
sourceType: string;
dashboardUrl?: string;
landingUrl?: string;
}
export function BillingLimitExceededEmail({
projectName = 'My Project',
projectId = 'proj_example123',
usage = 10000,
limit = 10000,
sourceType = 'Transactional',
dashboardUrl = 'https://app.useplunk.com',
landingUrl = 'https://www.useplunk.com',
}: BillingLimitExceededEmailProps) {
return (
<EmailLayout>
<Header />
<Section className="px-8 pb-10 pt-10">
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">
Email sending paused
</Heading>
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has reached your configured
monthly billing limit. Email sending has been paused to prevent charges beyond your set limit.
</Text>
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
<Section className="bg-gray-50 px-6 py-4">
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
Usage this month
</Text>
</Section>
<Section className="px-6 py-6">
<Section className="mb-6">
<Section className="mb-2 flex items-baseline justify-between">
<Text className="mb-0 mt-0 text-sm text-gray-600">Emails sent</Text>
<Text className="mb-0 mt-0 text-sm font-medium text-gray-900">
{usage.toLocaleString()} / {limit.toLocaleString()}
</Text>
</Section>
<Section className="h-2 overflow-hidden rounded-full bg-gray-200">
<Section className="h-full bg-gray-900" style={{width: '100%'}} />
</Section>
</Section>
<Text className="mb-0 mt-0 text-xs text-gray-500">{sourceType} emails</Text>
</Section>
</Section>
<Section className="mb-8 rounded-lg bg-red-50 px-6 py-4" style={{border: '1px solid #fca5a5'}}>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-red-900">
All email sending is currently blocked. Your usage will reset at the start of next month, or you can
increase your billing limit to resume immediately.
</Text>
</Section>
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">How to resume sending</Heading>
<Section className="mb-8">
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Increase your billing limit</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Resume sending immediately by adjusting your monthly limit in billing settings
</Text>
</Section>
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Wait for monthly reset</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Your usage will automatically reset at the start of next month
</Text>
</Section>
<Section>
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Contact support</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Need immediate help? Our team is here to assist you
</Text>
</Section>
</Section>
<Section className="mb-6">
<Link
href={`${dashboardUrl}/settings?tab=billing`}
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
>
Adjust billing limit
</Link>
</Section>
<Section>
<Link href={dashboardUrl} className="text-sm text-gray-500" style={{textDecoration: 'none'}}>
View project dashboard
</Link>
</Section>
</Section>
<Footer projectId={projectId} landingUrl={landingUrl} />
</EmailLayout>
);
}
export default BillingLimitExceededEmail;
@@ -0,0 +1,119 @@
import {Heading, Link, Section, Text} from '@react-email/components';
import * as React from 'react';
import {EmailLayout} from '../common/EmailLayout';
import {Footer} from '../common/Footer';
import {Header} from '../common/Header';
interface BillingLimitWarningEmailProps {
projectName: string;
projectId: string;
usage: number;
limit: number;
percentage: number;
sourceType: string;
dashboardUrl?: string;
landingUrl?: string;
}
export function BillingLimitWarningEmail({
projectName = 'My Project',
projectId = 'proj_example123',
usage = 8500,
limit = 10000,
percentage = 85,
sourceType = 'Transactional',
dashboardUrl = 'https://app.useplunk.com',
landingUrl = 'https://www.useplunk.com',
}: BillingLimitWarningEmailProps) {
const percentageRounded = Math.round(percentage);
const remaining = limit - usage;
return (
<EmailLayout>
<Header />
<Section className="px-8 pb-10 pt-10">
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">Usage limit warning</Heading>
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has used{' '}
<strong className="font-medium text-gray-900">{percentageRounded}%</strong> of your configured monthly billing
limit.
</Text>
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
<Section className="bg-gray-50 px-6 py-4">
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
Usage this month
</Text>
</Section>
<Section className="px-6 py-6">
<Section className="mb-6">
<Section className="mb-2 flex items-baseline justify-between">
<Text className="mb-0 mt-0 text-sm text-gray-600">Emails sent</Text>
<Text className="mb-0 mt-0 text-sm font-medium text-gray-900">
{usage.toLocaleString()} / {limit.toLocaleString()}
</Text>
</Section>
<Section className="h-2 overflow-hidden rounded-full bg-gray-200">
<div className="h-full bg-gray-900" style={{width: `${percentageRounded}%`, height: '100%'}} />
</Section>
</Section>
<Text className="mb-0 mt-0 text-xs text-gray-500">{sourceType} emails</Text>
</Section>
</Section>
<Section className="mb-8 rounded-lg bg-amber-50 px-6 py-4" style={{border: '1px solid #fbbf24'}}>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-amber-900">
When you reach 100%, email sending will be paused to prevent charges beyond your configured limit. Your
usage will reset at the start of next month.
</Text>
</Section>
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">Recommended actions</Heading>
<Section className="mb-8">
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Increase your billing limit</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Adjust your monthly limit in billing settings to continue sending
</Text>
</Section>
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Monitor your usage</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Track your sending patterns in the dashboard
</Text>
</Section>
<Section>
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Optimize your sending</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Review and reduce email volume where possible
</Text>
</Section>
</Section>
<Section className="mb-6">
<Link
href={`${dashboardUrl}/settings?tab=billing`}
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
>
Adjust billing limit
</Link>
</Section>
<Section>
<Link href={dashboardUrl} className="text-sm text-gray-500" style={{textDecoration: 'none'}}>
View project dashboard
</Link>
</Section>
</Section>
<Footer projectId={projectId} landingUrl={landingUrl} />
</EmailLayout>
);
}
export default BillingLimitWarningEmail;
@@ -0,0 +1,96 @@
import {Heading, Link, Section, Text} from '@react-email/components';
import * as React from 'react';
import {EmailLayout} from '../common/EmailLayout';
import {Footer} from '../common/Footer';
import {Header} from '../common/Header';
interface ProjectDisabledEmailProps {
projectName: string;
projectId: string;
violations: string[];
dashboardUrl?: string;
landingUrl?: string;
}
export function ProjectDisabledEmail({
projectName = 'My Project',
projectId = 'proj_example123',
violations = ['Bounce rate exceeded 10% threshold', 'Complaint rate exceeded 0.5% threshold'],
dashboardUrl = 'https://app.useplunk.com',
landingUrl = 'https://www.useplunk.com',
}: ProjectDisabledEmailProps) {
return (
<EmailLayout>
<Header />
<Section className="px-8 pb-10 pt-10">
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">Project disabled</Heading>
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has been automatically
disabled to protect your sender reputation.
</Text>
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
<Section className="bg-gray-50 px-6 py-4">
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
Issues detected
</Text>
</Section>
<Section className="px-6 py-6">
{violations.map((violation, index) => (
<Section key={index} className="mb-3 last:mb-0">
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-700">{violation}</Text>
</Section>
))}
</Section>
</Section>
<Section className="mb-8 rounded-lg bg-red-50 px-6 py-4" style={{border: '1px solid #fca5a5'}}>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-red-900">
High bounce or complaint rates can severely damage your sender reputation and email deliverability. We've
disabled your project to prevent further issues.
</Text>
</Section>
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">Steps to restore your project</Heading>
<Section className="mb-8">
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Review your email lists</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Remove invalid or unengaged contacts
</Text>
</Section>
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Verify recipient consent</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Ensure you have proper consent from all recipients
</Text>
</Section>
<Section>
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Check email content</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Verify your content follows best practices and isn't triggering spam filters
</Text>
</Section>
</Section>
<Section className="mb-6">
<Link
href={dashboardUrl}
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
>
View project dashboard
</Link>
</Section>
</Section>
<Footer projectId={projectId} landingUrl={landingUrl} />
</EmailLayout>
);
}
export default ProjectDisabledEmail;
+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 './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 {
to: string;
from: string;
subject: string;
body: string;
}
export async function sendEmail({to, subject, body}: SendEmailParams) {
const apiUrl = process.env.API_URI ?? 'http://localhost:8080';
export async function sendEmail({to, from, subject, body}: SendEmailParams) {
const apiUrl = process.env.API_URI;
const res = await fetch(`${apiUrl}/v1/send`, {
method: 'POST',
headers: {
@@ -14,6 +15,7 @@ export async function sendEmail({to, subject, body}: SendEmailParams) {
},
body: JSON.stringify({
to,
from,
subject,
body,
}),
+16 -18
View File
@@ -142,8 +142,8 @@ export const TemplateSchemas = {
subject: z.string().min(1),
body: z.string().min(1),
from: email,
fromName: z.string().max(100).optional(),
replyTo: email.optional(),
fromName: z.string().max(100).nullish(),
replyTo: email.nullish(),
type: z.nativeEnum(TemplateType).default('MARKETING'),
}),
update: z.object({
@@ -152,8 +152,8 @@ export const TemplateSchemas = {
subject: z.string().min(1).optional(),
body: z.string().min(1).optional(),
from: email.optional(),
fromName: z.string().max(100).optional(),
replyTo: email.optional(),
fromName: z.string().max(100).nullish(),
replyTo: email.nullish(),
type: z.nativeEnum(TemplateType).optional(),
}),
};
@@ -282,8 +282,8 @@ export const CampaignSchemas = {
subject: z.string().min(1),
body: z.string().min(1),
from: email,
fromName: z.string().max(100).optional(),
replyTo: email.optional(),
fromName: z.string().max(100).nullish(),
replyTo: email.nullish(),
audienceType: z.nativeEnum(CampaignAudienceType),
audienceCondition: filterConditionSchema.optional(),
segmentId: uuid.optional(),
@@ -297,8 +297,8 @@ export const CampaignSchemas = {
subject: z.string().optional(),
body: z.string().optional(),
from: z.string().optional(),
fromName: z.string().max(100).optional(),
replyTo: z.string().optional(),
fromName: z.string().max(100).nullish(),
replyTo: z.string().nullish(),
audienceType: z.nativeEnum(CampaignAudienceType).optional(),
audienceCondition: filterConditionSchema.optional(),
segmentId: z.string().optional(),
@@ -340,16 +340,14 @@ export const ActionSchemas = {
template: uuid.optional(),
subscribed: z.boolean().optional().default(false),
name: z.string().optional(),
from: z
.union([
email, // Simple email string (backward compatible)
z.object({
// Object with name and email
name: z.string().optional(),
email: email,
}),
])
.optional(),
from: z.union([
email, // Simple email string (backward compatible)
z.object({
// Object with name and email
name: z.string().optional(),
email: email,
}),
]),
reply: email.optional(),
headers: z.record(z.string().max(998)).optional(),
data: jsonSchema.optional(),
+37
View File
@@ -55,3 +55,40 @@ export interface SegmentMembershipComputeResult {
removed: number;
total: number;
}
// Security status types
export interface SecurityRateData {
total: number;
bounces: number;
complaints: number;
bounceRate: number;
complaintRate: number;
}
export interface SecurityStatus {
projectId: string;
isHealthy: boolean;
shouldDisable: boolean;
sevenDay: SecurityRateData;
allTime: SecurityRateData;
violations: string[];
warnings: string[];
}
export interface SecurityThresholds {
MIN_EMAILS_FOR_ENFORCEMENT: number;
BOUNCE_7DAY_WARNING: number;
BOUNCE_7DAY_CRITICAL: number;
BOUNCE_ALLTIME_WARNING: number;
BOUNCE_ALLTIME_CRITICAL: number;
COMPLAINT_7DAY_WARNING: number;
COMPLAINT_7DAY_CRITICAL: number;
COMPLAINT_ALLTIME_WARNING: number;
COMPLAINT_ALLTIME_CRITICAL: number;
}
export interface ProjectSecurityMetrics {
status: SecurityStatus;
thresholds: SecurityThresholds;
isDisabled: boolean;
}
+3 -2
View File
@@ -15,8 +15,8 @@
"typescript": "^5.7.2"
},
"peerDependencies": {
"react": "^19.2.1",
"react-dom": "^19.2.1"
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -57,6 +57,7 @@
"next-themes": "^0.4.6",
"react-day-picker": "^8.10.0",
"react-hook-form": "^7.66.0",
"recharts": "^3.5.1",
"sonner": "^2.0.6",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
+1 -1
View File
@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
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,
)}
{...props}
+9 -3
View File
@@ -65,7 +65,9 @@
"NEXT_PUBLIC_API_URI",
"NEXT_PUBLIC_LANDING_URI",
"NEXT_PUBLIC_DASHBOARD_URI",
"NEXT_PUBLIC_WIKI_URI"
"NEXT_PUBLIC_WIKI_URI",
"PLUNK_API_KEY",
"PLUNK_FROM_ADDRESS"
],
"inputs": ["$TURBO_DEFAULT$", ".env*"]
},
@@ -113,7 +115,9 @@
"NEXT_PUBLIC_API_URI",
"NEXT_PUBLIC_LANDING_URI",
"NEXT_PUBLIC_DASHBOARD_URI",
"NEXT_PUBLIC_WIKI_URI"
"NEXT_PUBLIC_WIKI_URI",
"PLUNK_API_KEY",
"PLUNK_FROM_ADDRESS"
]
},
"dev:worker": {
@@ -152,7 +156,9 @@
"NEXT_PUBLIC_API_URI",
"NEXT_PUBLIC_LANDING_URI",
"NEXT_PUBLIC_DASHBOARD_URI",
"NEXT_PUBLIC_WIKI_URI"
"NEXT_PUBLIC_WIKI_URI",
"PLUNK_API_KEY",
"PLUNK_FROM_ADDRESS"
]
},
"clean": {
+1
View File
@@ -47,6 +47,7 @@ export default defineConfig({
'@plunk/db': path.resolve(__dirname, './packages/db/src'),
'@plunk/shared': path.resolve(__dirname, './packages/shared/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