feat: Added platform emails for billing limits and disabled projects

This commit is contained in:
Dries Augustyns
2025-12-12 12:28:53 +01:00
parent cb40669385
commit 2485d2ff1d
29 changed files with 768 additions and 67 deletions
+2 -1
View File
@@ -3,6 +3,7 @@ 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
@@ -171,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);
+4 -3
View File
@@ -1,5 +1,6 @@
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
/**
* Time series data point for analytics
@@ -57,7 +58,7 @@ export class AnalyticsService {
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
// Check cache first
const cacheKey = `analytics:timeseries:${projectId}:${limitedStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
const cacheKey = Keys.Analytics.timeseries(projectId, limitedStartDate.toISOString(), effectiveEndDate.toISOString());
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
@@ -190,7 +191,7 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = `analytics:campaignStats:${projectId}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
const cacheKey = Keys.Analytics.campaignStats(projectId, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
@@ -294,7 +295,7 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = `analytics:topEvents:${projectId}:${limit}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
const cacheKey = Keys.Analytics.topEvents(projectId, limit, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
+138 -3
View File
@@ -1,10 +1,13 @@
import {EmailSourceType} from '@plunk/db';
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
import {STRIPE_ENABLED} from '../app/constants.js';
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';
/**
@@ -238,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,
@@ -259,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 {
@@ -299,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 {
@@ -322,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);
}
}
@@ -484,7 +513,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);
}
/**
@@ -496,4 +525,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);
}
}
}
+3 -2
View File
@@ -5,6 +5,7 @@ import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
@@ -49,7 +50,7 @@ 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) {
@@ -323,7 +324,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 {
+29 -10
View File
@@ -1,9 +1,13 @@
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {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) {
@@ -137,7 +140,7 @@ 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);
@@ -167,13 +170,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 +341,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,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: 'test@example.com',
from: 'test@example.com',
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: 'test@example.com',
subject: 'Test',
@@ -748,7 +745,6 @@ describe('EmailService', () => {
});
it('should reject attachment with empty filename', () => {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
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: 'test@example.com',
from: 'test@example.com',
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
@@ -1,6 +1,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
import {WorkflowService} from '../WorkflowService';
import {Keys} from '../keys';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock Redis for caching tests - must be inline to avoid hoisting issues
@@ -130,7 +131,7 @@ describe('WorkflowService', () => {
it('should invalidate cache when creating enabled workflow', async () => {
const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
// Set cache
await redis.set(cacheKey, JSON.stringify([{id: 'old'}]));
@@ -286,7 +287,7 @@ describe('WorkflowService', () => {
it('should invalidate cache when enabling workflow', async () => {
const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
const workflow = await factories.createWorkflow({
projectId,
@@ -326,7 +327,7 @@ describe('WorkflowService', () => {
it('should invalidate cache when deleting enabled workflow', async () => {
const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
const workflow = await factories.createWorkflow({
projectId,
+37
View File
@@ -15,4 +15,41 @@ export const Keys = {
return `domain:project:${projectId}`;
},
},
Billing: {
usage(projectId: string, sourceType: string, year: number, month: string): string {
return `billing:usage:${projectId}:${sourceType}:${year}-${month}`;
},
warningEmail(projectId: string, sourceType: string, year: number, month: string): string {
return `billing:warning_email:${projectId}:${sourceType}:${year}-${month}`;
},
limitEmail(projectId: string, sourceType: string, year: number, month: string): string {
return `billing:limit_email:${projectId}:${sourceType}:${year}-${month}`;
},
},
Security: {
rates(projectId: string): string {
return `security:${projectId}:rates`;
},
},
Activity: {
stats(projectId: string, startTime: number | string, endTime: number | string): string {
return `activity:stats:${projectId}:${startTime}:${endTime}`;
},
},
Analytics: {
timeseries(projectId: string, startDate: string, endDate: string): string {
return `analytics:timeseries:${projectId}:${startDate}:${endDate}`;
},
campaignStats(projectId: string, startDate: string, endDate: string): string {
return `analytics:campaignStats:${projectId}:${startDate}:${endDate}`;
},
topEvents(projectId: string, limit: number, startDate: string, endDate: string): string {
return `analytics:topEvents:${projectId}:${limit}:${startDate}:${endDate}`;
},
},
Workflow: {
enabled(projectId: string): string {
return `workflows:enabled:${projectId}`;
},
},
} as const;