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