diff --git a/.env.self-host.example b/.env.self-host.example index caa7a2a..1066999 100644 --- a/.env.self-host.example +++ b/.env.self-host.example @@ -112,6 +112,15 @@ 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= + # ======================================== # ADVANCED (rarely needed) # ======================================== diff --git a/CLAUDE.md b/CLAUDE.md index d2238ef..d7855aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:** diff --git a/apps/api/src/__tests__/integration/actions.test.ts b/apps/api/src/__tests__/integration/actions.test.ts index 1b27e1d..0333605 100644 --- a/apps/api/src/__tests__/integration/actions.test.ts +++ b/apps/api/src/__tests__/integration/actions.test.ts @@ -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: 'test@example.com', + from: 'test@example.com', // 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: 'test@example.com', + from: 'test@example.com', subject: 'Test', body: 'Test', }); @@ -182,6 +184,7 @@ describe('Actions API Integration Tests', () => { name: 'Jane Doe', email: 'test@example.com', }, + from: 'test@example.com', subject: 'Test', body: 'Test', }); @@ -194,6 +197,7 @@ describe('Actions API Integration Tests', () => { to: { email: 'test@example.com', }, + from: 'test@example.com', 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: ['test1@example.com', 'test2@example.com'], + from: 'test@example.com', subject: 'Test', body: 'Test', }); @@ -217,6 +222,7 @@ describe('Actions API Integration Tests', () => { {name: 'Jane Doe', email: 'test1@example.com'}, {name: 'John Smith', email: 'test2@example.com'}, ], + from: 'test@example.com', 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: ['test1@example.com', {name: 'John Smith', email: 'test2@example.com'}], + from: 'test@example.com', subject: 'Test', body: 'Test', }); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 20ed2d4..73eda76 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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'; @@ -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 => ({ diff --git a/apps/api/src/app/constants.ts b/apps/api/src/app/constants.ts index e93792e..a640b25 100644 --- a/apps/api/src/app/constants.ts +++ b/apps/api/src/app/constants.ts @@ -94,3 +94,7 @@ 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 !== ''; diff --git a/apps/api/src/controllers/Actions.ts b/apps/api/src/controllers/Actions.ts index 2a5713e..18ccbfd 100644 --- a/apps/api/src/controllers/Actions.ts +++ b/apps/api/src/controllers/Actions.ts @@ -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'; /** @@ -223,14 +223,21 @@ export class Actions { templateId = templateRecord.id; } - // Verify 'from' domain is verified if provided - const senderEmail = emailFrom || 'noreply@useplunk.com'; // Default sender - - // Only verify custom domains (not the default noreply@useplunk.com) - if (emailFrom && emailFrom !== 'noreply@useplunk.com') { - 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 +288,7 @@ export class Actions { contactId: contact.id, subject: renderedSubject, body: renderedBody, - from: senderEmail, + from: emailFrom, fromName: emailFromName, toName: recipient.name, replyTo: replyToEmail, diff --git a/apps/api/src/services/ActivityService.ts b/apps/api/src/services/ActivityService.ts index 5fb3b85..f46e47e 100644 --- a/apps/api/src/services/ActivityService.ts +++ b/apps/api/src/services/ActivityService.ts @@ -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 { // 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); diff --git a/apps/api/src/services/AnalyticsService.ts b/apps/api/src/services/AnalyticsService.ts index e226974..917c220 100644 --- a/apps/api/src/services/AnalyticsService.ts +++ b/apps/api/src/services/AnalyticsService.ts @@ -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); diff --git a/apps/api/src/services/BillingLimitService.ts b/apps/api/src/services/BillingLimitService.ts index 50b49b1..8b56796 100644 --- a/apps/api/src/services/BillingLimitService.ts +++ b/apps/api/src/services/BillingLimitService.ts @@ -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 { + 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 { + 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); + } + } } diff --git a/apps/api/src/services/EventService.ts b/apps/api/src/services/EventService.ts index 6860d2d..2ad7a2d 100644 --- a/apps/api/src/services/EventService.ts +++ b/apps/api/src/services/EventService.ts @@ -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 { - 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, ): Promise { // Try to get workflows from cache - const cacheKey = `workflows:enabled:${projectId}`; + const cacheKey = Keys.Workflow.enabled(projectId); let workflows; try { diff --git a/apps/api/src/services/SecurityService.ts b/apps/api/src/services/SecurityService.ts index 848bf5e..6c8b39c 100644 --- a/apps/api/src/services/SecurityService.ts +++ b/apps/api/src/services/SecurityService.ts @@ -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 { 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 { 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); } diff --git a/apps/api/src/services/__tests__/EmailService.test.ts b/apps/api/src/services/__tests__/EmailService.test.ts index 4308f2e..0d6e141 100644 --- a/apps/api/src/services/__tests__/EmailService.test.ts +++ b/apps/api/src/services/__tests__/EmailService.test.ts @@ -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: [ diff --git a/apps/api/src/services/__tests__/EventService.test.ts b/apps/api/src/services/__tests__/EventService.test.ts index 2e102df..2fe9d39 100644 --- a/apps/api/src/services/__tests__/EventService.test.ts +++ b/apps/api/src/services/__tests__/EventService.test.ts @@ -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 diff --git a/apps/api/src/services/__tests__/WorkflowService.test.ts b/apps/api/src/services/__tests__/WorkflowService.test.ts index 2075a47..f2c4438 100644 --- a/apps/api/src/services/__tests__/WorkflowService.test.ts +++ b/apps/api/src/services/__tests__/WorkflowService.test.ts @@ -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, diff --git a/apps/api/src/services/keys.ts b/apps/api/src/services/keys.ts index 4704c36..8479af8 100644 --- a/apps/api/src/services/keys.ts +++ b/apps/api/src/services/keys.ts @@ -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; diff --git a/apps/web/src/components/BillingLimits.tsx b/apps/web/src/components/BillingLimits.tsx index 5d757e2..b638a6c 100644 --- a/apps/web/src/components/BillingLimits.tsx +++ b/apps/web/src/components/BillingLimits.tsx @@ -406,7 +406,7 @@ const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: Usa
-

+

Limit reached: No more {category.toLowerCase()} emails can be sent this month.

@@ -417,7 +417,7 @@ const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: Usa
-

+

Warning: You've used {Math.round(usage.percentage)}% of your{' '} {category.toLowerCase()} email limit.

diff --git a/packages/email/package.json b/packages/email/package.json index 63378ad..c3939f1 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -21,7 +21,8 @@ "dependencies": { "@plunk/db": "*", "@plunk/types": "*", - "@react-email/components": "^1.0.0" + "@react-email/components": "^1.0.0", + "@react-email/tailwind": "^2.0.1" }, "exports": { ".": "./dist/index.js" diff --git a/packages/email/src/common/EmailLayout.tsx b/packages/email/src/common/EmailLayout.tsx new file mode 100644 index 0000000..2bb6b39 --- /dev/null +++ b/packages/email/src/common/EmailLayout.tsx @@ -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 ( + + + + + + + + + {children} + + + + + ); +} diff --git a/packages/email/src/common/Footer.tsx b/packages/email/src/common/Footer.tsx new file mode 100644 index 0000000..832033d --- /dev/null +++ b/packages/email/src/common/Footer.tsx @@ -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 ( +
+ + This email was sent by Plunk. . + + {projectId && ( + + Project ID: {projectId} + + )} + + + Plunk + + {' • '} + + Privacy + + {' • '} + + Terms + + +
+ ); +} diff --git a/packages/email/src/common/Header.tsx b/packages/email/src/common/Header.tsx index ee2afc8..a4556cc 100644 --- a/packages/email/src/common/Header.tsx +++ b/packages/email/src/common/Header.tsx @@ -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 ( - <> - Swyp Logo - +
+ Plunk +
); } diff --git a/packages/email/src/emails/BillingLimitExceeded.tsx b/packages/email/src/emails/BillingLimitExceeded.tsx new file mode 100644 index 0000000..f5b6023 --- /dev/null +++ b/packages/email/src/emails/BillingLimitExceeded.tsx @@ -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 ( + +
+ +
+ + Email sending paused + + + + Your project {projectName} has reached your configured + monthly billing limit. Email sending has been paused to prevent charges beyond your set limit. + + +
+
+ + Usage this month + +
+
+
+
+ Emails sent + + {usage.toLocaleString()} / {limit.toLocaleString()} + +
+
+
+
+
+ {sourceType} emails +
+
+ +
+ + 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. + +
+ + How to resume sending + +
+
+ Increase your billing limit + + Resume sending immediately by adjusting your monthly limit in billing settings + +
+ +
+ Wait for monthly reset + + Your usage will automatically reset at the start of next month + +
+ +
+ Contact support + + Need immediate help? Our team is here to assist you + +
+
+ +
+ + Adjust billing limit + +
+ +
+ + View project dashboard → + +
+
+ +