From c0b2abaeca5656743e189f975ab82ff9b25b577b Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Wed, 15 Apr 2026 20:55:42 +0200 Subject: [PATCH] feat: Enhance security metrics handling with new thresholds and improved messaging --- apps/api/src/services/SecurityService.ts | 204 +++++++++++++- .../__tests__/SecurityService.test.ts | 265 ++++++++++++++++++ apps/web/src/components/SecuritySettings.tsx | 230 +++++---------- .../src/components/SecurityWarningBanner.tsx | 19 +- packages/types/src/security/index.ts | 21 +- 5 files changed, 538 insertions(+), 201 deletions(-) create mode 100644 apps/api/src/services/__tests__/SecurityService.test.ts diff --git a/apps/api/src/services/SecurityService.ts b/apps/api/src/services/SecurityService.ts index 13379a0..aa96993 100644 --- a/apps/api/src/services/SecurityService.ts +++ b/apps/api/src/services/SecurityService.ts @@ -15,7 +15,7 @@ import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants * These limits protect AWS SES reputation and prevent account suspension */ const SECURITY_THRESHOLDS = { - // Minimum emails required before enforcing limits (prevents false positives) + // Minimum emails required before enforcing rate-based limits (prevents false positives) MIN_EMAILS_FOR_ENFORCEMENT: 100, // Bounce rate thresholds (hard bounces only) @@ -31,11 +31,37 @@ const SECURITY_THRESHOLDS = { COMPLAINT_ALLTIME_CRITICAL: 0.12, // Minimum absolute counts (prevents small sample size false positives) - // Both percentage AND absolute count must be exceeded to trigger + // Both percentage AND absolute count must be exceeded to trigger rate-based checks MIN_BOUNCES_FOR_CRITICAL: 10, MIN_BOUNCES_FOR_WARNING: 5, MIN_COMPLAINTS_FOR_CRITICAL: 5, MIN_COMPLAINTS_FOR_WARNING: 3, + + // === Absolute count ceilings === + // These trigger regardless of rate — catches high-volume spammers who dilute their bounce rate + // 24-hour absolute ceilings + BOUNCE_24H_CEILING_WARNING: 50, + BOUNCE_24H_CEILING_CRITICAL: 100, + COMPLAINT_24H_CEILING_WARNING: 10, + COMPLAINT_24H_CEILING_CRITICAL: 25, + + // 7-day absolute ceilings + BOUNCE_7DAY_CEILING_WARNING: 200, + BOUNCE_7DAY_CEILING_CRITICAL: 500, + COMPLAINT_7DAY_CEILING_WARNING: 30, + COMPLAINT_7DAY_CEILING_CRITICAL: 75, + + // === New project thresholds (projects < 30 days old) === + // Legitimate senders ramp up gradually; spammers blast immediately + NEW_PROJECT_AGE_DAYS: 30, + NEW_PROJECT_BOUNCE_24H_CEILING_WARNING: 20, + NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL: 50, + NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING: 75, + NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL: 150, + NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING: 5, + NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL: 10, + NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING: 15, + NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL: 30, } as const; interface RateData { @@ -50,8 +76,10 @@ interface SecurityStatus { projectId: string; isHealthy: boolean; shouldDisable: boolean; + twentyFourHour: RateData; sevenDay: RateData; allTime: RateData; + isNewProject: boolean; violations: string[]; warnings: string[]; } @@ -86,6 +114,13 @@ export class SecurityService { projectId, isHealthy: true, shouldDisable: false, + twentyFourHour: { + total: 0, + bounces: 0, + complaints: 0, + bounceRate: 0, + complaintRate: 0, + }, sevenDay: { total: 0, bounces: 0, @@ -100,6 +135,7 @@ export class SecurityService { bounceRate: 0, complaintRate: 0, }, + isNewProject: false, violations: [], warnings: [], }; @@ -133,12 +169,18 @@ export class SecurityService { `[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`, status.violations, ); + signale.info( + `[SECURITY] 24-hour stats: ${status.twentyFourHour.bounces} bounces, ${status.twentyFourHour.complaints} complaints out of ${status.twentyFourHour.total} emails`, + ); 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`, ); + if (status.isNewProject) { + signale.info(`[SECURITY] Project is under ${SECURITY_THRESHOLDS.NEW_PROJECT_AGE_DAYS} days old — stricter ceilings apply`); + } // Send notification about critical security violations await NtfyService.notifySecurityWarning(project.name, projectId, status.violations); @@ -201,11 +243,17 @@ export class SecurityService { } /** - * Get a project's security metrics (for admin/dashboard display) + * Get a project's security metrics (for dashboard display) + * Does NOT expose internal thresholds — only computed health levels */ public static async getProjectSecurityMetrics(projectId: string): Promise<{ status: SecurityStatus; - thresholds: typeof SECURITY_THRESHOLDS; + levels: { + bounce7Day: 'healthy' | 'warning' | 'critical'; + bounceAllTime: 'healthy' | 'warning' | 'critical'; + complaint7Day: 'healthy' | 'warning' | 'critical'; + complaintAllTime: 'healthy' | 'warning' | 'critical'; + }; isDisabled: boolean; }> { const [status, project] = await Promise.all([ @@ -216,13 +264,55 @@ export class SecurityService { }), ]); + // Strip internal details from the client-facing response: + // - Replace detailed violation/warning messages (they contain exact thresholds) + // - Remove 24-hour data and new project flag (reveals enforcement windows) + const sanitizedStatus: SecurityStatus = { + ...status, + twentyFourHour: {total: 0, bounces: 0, complaints: 0, bounceRate: 0, complaintRate: 0}, + isNewProject: false, + violations: status.violations.map(() => 'Security threshold exceeded'), + warnings: status.warnings.map(() => 'Approaching security threshold'), + }; + return { - status, - thresholds: SECURITY_THRESHOLDS, + status: sanitizedStatus, + levels: { + bounce7Day: this.computeLevel( + status.sevenDay.bounceRate, + SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING, + SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL, + ), + bounceAllTime: this.computeLevel( + status.allTime.bounceRate, + SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING, + SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL, + ), + complaint7Day: this.computeLevel( + status.sevenDay.complaintRate, + SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING, + SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL, + ), + complaintAllTime: this.computeLevel( + status.allTime.complaintRate, + SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING, + SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL, + ), + }, isDisabled: project?.disabled ?? false, }; } + private static computeLevel( + value: number, + warningThreshold: number, + criticalThreshold: number, + ): 'healthy' | 'warning' | 'critical' { + if (value >= criticalThreshold) return 'critical'; + if (value >= warningThreshold) return 'warning'; + return 'healthy'; + } + /** * Calculate bounce and complaint rates for a project */ @@ -270,10 +360,23 @@ export class SecurityService { */ private static async calculateSecurityStatus(projectId: string): Promise { const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - // Get 7-day and all-time rates in parallel - const [sevenDay, allTime] = await Promise.all([ + // Get project age to determine if stricter new-project thresholds apply + const project = await prisma.project.findUnique({ + where: {id: projectId}, + select: {createdAt: true}, + }); + + const projectAgeDays = project + ? (now.getTime() - project.createdAt.getTime()) / (1000 * 60 * 60 * 24) + : Infinity; + const isNewProject = projectAgeDays < SECURITY_THRESHOLDS.NEW_PROJECT_AGE_DAYS; + + // Get 24-hour, 7-day and all-time rates in parallel + const [twentyFourHour, sevenDay, allTime] = await Promise.all([ + this.calculateRates(projectId, oneDayAgo), this.calculateRates(projectId, sevenDaysAgo), this.calculateRates(projectId), ]); @@ -281,13 +384,91 @@ export class SecurityService { const violations: string[] = []; const warnings: string[] = []; + // Pick absolute count ceilings based on project age + const bounceCeilings = isNewProject + ? { + ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING, + ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL, + ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING, + ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL, + } + : { + ceiling24hWarning: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_WARNING, + ceiling24hCritical: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_CRITICAL, + ceiling7dWarning: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_WARNING, + ceiling7dCritical: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_CRITICAL, + }; + + const complaintCeilings = isNewProject + ? { + ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING, + ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL, + ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING, + ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL, + } + : { + ceiling24hWarning: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_WARNING, + ceiling24hCritical: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_CRITICAL, + ceiling7dWarning: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_WARNING, + ceiling7dCritical: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_CRITICAL, + }; + + const projectLabel = isNewProject ? ' (new project)' : ''; + + // === Absolute count ceiling checks (rate-independent) === + // These catch high-volume spammers who dilute their bounce rate by blasting emails + + // 24-hour bounce ceilings + if (twentyFourHour.bounces >= bounceCeilings.ceiling24hCritical) { + violations.push( + `24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling24hCritical})`, + ); + } else if (twentyFourHour.bounces >= bounceCeilings.ceiling24hWarning) { + warnings.push( + `24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling24hWarning})`, + ); + } + + // 7-day bounce ceilings + if (sevenDay.bounces >= bounceCeilings.ceiling7dCritical) { + violations.push( + `7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling7dCritical})`, + ); + } else if (sevenDay.bounces >= bounceCeilings.ceiling7dWarning) { + warnings.push( + `7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling7dWarning})`, + ); + } + + // 24-hour complaint ceilings + if (twentyFourHour.complaints >= complaintCeilings.ceiling24hCritical) { + violations.push( + `24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling24hCritical})`, + ); + } else if (twentyFourHour.complaints >= complaintCeilings.ceiling24hWarning) { + warnings.push( + `24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling24hWarning})`, + ); + } + + // 7-day complaint ceilings + if (sevenDay.complaints >= complaintCeilings.ceiling7dCritical) { + violations.push( + `7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling7dCritical})`, + ); + } else if (sevenDay.complaints >= complaintCeilings.ceiling7dWarning) { + warnings.push( + `7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling7dWarning})`, + ); + } + + // === Rate-based checks (existing logic) === // Only enforce if minimum emails threshold is met const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT; const hasMinimumVolume7Day = sevenDay.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT; // Check 7-day bounce rate (only if 7-day volume is sufficient) if (hasMinimumVolume7Day) { - // Critical: requires BOTH rate AND absolute count thresholds if ( sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL && sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL @@ -307,7 +488,6 @@ export class SecurityService { // Check 7-day complaint rate (only if 7-day volume is sufficient) if (hasMinimumVolume7Day) { - // Critical: requires BOTH rate AND absolute count thresholds if ( sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL && sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL @@ -327,7 +507,6 @@ export class SecurityService { // Check all-time rates (only if all-time volume is sufficient) if (hasMinimumVolumeAllTime) { - // Check all-time bounce rate - requires BOTH rate AND absolute count if ( allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL && allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL @@ -344,7 +523,6 @@ export class SecurityService { ); } - // Check all-time complaint rate - requires BOTH rate AND absolute count if ( allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL && allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL @@ -366,8 +544,10 @@ export class SecurityService { projectId, isHealthy: violations.length === 0, shouldDisable: violations.length > 0, + twentyFourHour, sevenDay, allTime, + isNewProject, violations, warnings, }; diff --git a/apps/api/src/services/__tests__/SecurityService.test.ts b/apps/api/src/services/__tests__/SecurityService.test.ts new file mode 100644 index 0000000..a429ac4 --- /dev/null +++ b/apps/api/src/services/__tests__/SecurityService.test.ts @@ -0,0 +1,265 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {EmailStatus, EmailSourceType} from '@plunk/db'; +import {SecurityService} from '../SecurityService'; +import {factories, getPrismaClient} from '../../../../../test/helpers'; +import {redis} from '../../database/redis'; + +vi.mock('../../app/constants.js', async () => { + const actual = await vi.importActual('../../app/constants.js'); + return { + ...actual, + AUTO_PROJECT_DISABLE: true, + }; +}); + +// Mock NtfyService to prevent actual notifications +vi.mock('../NtfyService.js', () => ({ + NtfyService: { + notifySecurityWarning: vi.fn(), + notifyProjectDisabledForSecurity: vi.fn(), + }, +})); + +// Mock email sending for project disabled notifications +vi.mock('@plunk/email', () => ({ + ProjectDisabledEmail: vi.fn(), + sendPlatformEmail: vi.fn(), +})); + +describe('SecurityService', () => { + let projectId: string; + let contactId: string; + const prisma = getPrismaClient(); + + beforeEach(async () => { + const {project} = await factories.createUserWithProject(); + projectId = project.id; + + const contact = await factories.createContact({projectId}); + contactId = contact.id; + + // Clear redis cache + await redis.flushdb(); + }); + + /** + * Helper to create N emails, some of which are bounced + */ + async function createEmails(count: number, opts?: {bouncedCount?: number; complainedCount?: number; createdAt?: Date}) { + const bouncedCount = opts?.bouncedCount ?? 0; + const complainedCount = opts?.complainedCount ?? 0; + const createdAt = opts?.createdAt ?? new Date(); + + const emails = []; + for (let i = 0; i < count; i++) { + emails.push( + prisma.email.create({ + data: { + projectId, + contactId, + subject: `Test ${i}`, + body: '

test

', + from: 'test@example.com', + status: EmailStatus.SENT, + sourceType: EmailSourceType.TRANSACTIONAL, + sentAt: createdAt, + createdAt, + bouncedAt: i < bouncedCount ? createdAt : null, + complainedAt: i >= bouncedCount && i < bouncedCount + complainedCount ? createdAt : null, + }, + }), + ); + } + await Promise.all(emails); + } + + describe('Rate-based checks (existing behavior)', () => { + it('should report healthy when bounce rate is below warning', async () => { + // 200 emails, 5 bounces = 2.5% (below 5% warning) + await createEmails(200, {bouncedCount: 5}); + + const status = await SecurityService.getSecurityStatus(projectId); + expect(status.isHealthy).toBe(true); + expect(status.shouldDisable).toBe(false); + expect(status.violations).toHaveLength(0); + expect(status.warnings).toHaveLength(0); + }); + + it('should trigger warning when 7-day bounce rate exceeds warning threshold', async () => { + // 100 emails, 6 bounces = 6% (above 5% warning, below 10% critical) + await createEmails(100, {bouncedCount: 6}); + + const status = await SecurityService.getSecurityStatus(projectId); + expect(status.isHealthy).toBe(true); + expect(status.warnings.length).toBeGreaterThan(0); + }); + + it('should trigger violation when 7-day bounce rate exceeds critical threshold', async () => { + // 100 emails, 11 bounces = 11% (above 10% critical) + await createEmails(100, {bouncedCount: 11}); + + const status = await SecurityService.getSecurityStatus(projectId); + expect(status.isHealthy).toBe(false); + expect(status.shouldDisable).toBe(true); + expect(status.violations.length).toBeGreaterThan(0); + }); + + it('should not enforce rate checks when below minimum volume', async () => { + // 50 emails (below 100 minimum), 10 bounces = 20% (would exceed critical) + await createEmails(50, {bouncedCount: 10}); + + const status = await SecurityService.getSecurityStatus(projectId); + // Rate-based check doesn't trigger, but absolute count ceiling might + // With 10 bounces in 24h, this is below the 50-bounce ceiling for established projects + expect(status.violations).toHaveLength(0); + }); + }); + + describe('Absolute count ceilings', () => { + it('should trigger critical when 24-hour bounce count exceeds ceiling', async () => { + // 20,000 emails, 101 bounces = 0.5% rate (well below rate threshold) + // But 101 bounces > 100 (24h critical ceiling for established projects) + await createEmails(20000, {bouncedCount: 101}); + + const status = await SecurityService.getSecurityStatus(projectId); + expect(status.shouldDisable).toBe(true); + expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(true); + }); + + it('should trigger warning when 24-hour bounce count exceeds warning ceiling', async () => { + // 10,000 emails, 51 bounces = 0.51% (below rate threshold) + // But 51 > 50 (24h warning ceiling) + await createEmails(10000, {bouncedCount: 51}); + + const status = await SecurityService.getSecurityStatus(projectId); + expect(status.isHealthy).toBe(true); // warnings don't make it unhealthy + expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(true); + }); + + it('should trigger critical when 24-hour complaint count exceeds ceiling', async () => { + // 20,000 emails, 26 complaints = 0.13% (below complaint rate critical of 0.15%) + // But 26 > 25 (24h complaint critical ceiling) + await createEmails(20000, {complainedCount: 26}); + + const status = await SecurityService.getSecurityStatus(projectId); + expect(status.shouldDisable).toBe(true); + expect(status.violations.some(v => v.includes('24-hour complaint count'))).toBe(true); + }); + + it('should NOT trigger ceiling when bounce count is below ceiling', async () => { + // 20,000 emails, 40 bounces = well below 100 critical and below 50 warning ceiling + await createEmails(20000, {bouncedCount: 40}); + + const status = await SecurityService.getSecurityStatus(projectId); + expect(status.isHealthy).toBe(true); + expect(status.violations).toHaveLength(0); + expect(status.warnings).toHaveLength(0); + }); + }); + + describe('New project stricter thresholds', () => { + it('should apply stricter ceilings for projects under 30 days old', async () => { + // Default project is created "now", so it's a new project + // 10,000 emails, 51 bounces (above 50 new project 24h critical ceiling) + await createEmails(10000, {bouncedCount: 51}); + + const status = await SecurityService.getSecurityStatus(projectId); + expect(status.isNewProject).toBe(true); + expect(status.shouldDisable).toBe(true); + expect(status.violations.some(v => v.includes('new project'))).toBe(true); + }); + + it('should apply standard ceilings for projects over 30 days old', async () => { + // Age the project to 31 days + const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); + await prisma.project.update({ + where: {id: projectId}, + data: {createdAt: oldDate}, + }); + + // 10,000 emails, 51 bounces (above 50 new project ceiling, below 100 standard ceiling) + await createEmails(10000, {bouncedCount: 51}); + + const status = await SecurityService.getSecurityStatus(projectId); + expect(status.isNewProject).toBe(false); + // 51 is above the 50-bounce 24h warning ceiling for established projects + expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(true); + // But below the 100-bounce 24h critical ceiling + expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(false); + }); + + it('should catch new project blasting emails with delayed bounces', async () => { + // Simulate the spammer scenario: new project sends 20K emails, + // only 55 bounces have come back so far (rate is tiny: 0.275%) + await createEmails(20000, {bouncedCount: 55}); + + const status = await SecurityService.getSecurityStatus(projectId); + expect(status.isNewProject).toBe(true); + expect(status.shouldDisable).toBe(true); + // 55 > 50 new project 24h critical ceiling + expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(true); + }); + }); + + describe('checkAndEnforceSecurityLimits', () => { + it('should disable project when critical thresholds are exceeded', async () => { + // Create enough bounces to trigger critical + await createEmails(20000, {bouncedCount: 101}); + + await SecurityService.checkAndEnforceSecurityLimits(projectId); + + const project = await prisma.project.findUnique({ + where: {id: projectId}, + select: {disabled: true}, + }); + expect(project?.disabled).toBe(true); + }); + + it('should NOT disable project when only warnings exist', async () => { + // 10,000 emails, 51 bounces (above warning but below critical for established project) + const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); + await prisma.project.update({ + where: {id: projectId}, + data: {createdAt: oldDate}, + }); + await createEmails(10000, {bouncedCount: 51}); + + await SecurityService.checkAndEnforceSecurityLimits(projectId); + + const project = await prisma.project.findUnique({ + where: {id: projectId}, + select: {disabled: true}, + }); + expect(project?.disabled).toBe(false); + }); + }); + + describe('getProjectSecurityMetrics (client-facing)', () => { + it('should NOT expose internal thresholds or detailed violation messages', async () => { + // Create a violation scenario + await createEmails(100, {bouncedCount: 15}); + + const metrics = await SecurityService.getProjectSecurityMetrics(projectId); + + // Should have levels, not thresholds + expect(metrics.levels).toBeDefined(); + expect((metrics as Record).thresholds).toBeUndefined(); + + // Violation messages should be generic + if (metrics.status.violations.length > 0) { + for (const v of metrics.status.violations) { + expect(v).toBe('Security threshold exceeded'); + expect(v).not.toMatch(/\d+%/); // No percentages + expect(v).not.toMatch(/\d+ minimum/); // No absolute numbers + } + } + + // 24-hour data should be zeroed out + expect(metrics.status.twentyFourHour.total).toBe(0); + expect(metrics.status.twentyFourHour.bounces).toBe(0); + + // New project flag should be hidden + expect(metrics.status.isNewProject).toBe(false); + }); + }); +}); diff --git a/apps/web/src/components/SecuritySettings.tsx b/apps/web/src/components/SecuritySettings.tsx index 8c82a63..0153d0c 100644 --- a/apps/web/src/components/SecuritySettings.tsx +++ b/apps/web/src/components/SecuritySettings.tsx @@ -1,22 +1,24 @@ -import { - Alert, - AlertDescription, - AlertTitle, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Progress, -} from '@plunk/ui'; +import {Alert, AlertDescription, AlertTitle, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui'; import {AlertCircle, AlertTriangle, CheckCircle, Shield} from 'lucide-react'; -import type {ProjectSecurityMetrics} from '@plunk/types'; +import type {ProjectSecurityMetrics, SecurityLevel} from '@plunk/types'; interface SecuritySettingsProps { metrics: ProjectSecurityMetrics; isLoading: boolean; } +const STATUS_CONFIG: Record = { + healthy: {color: 'text-green-600', icon: CheckCircle, bg: 'bg-green-100', label: 'Healthy'}, + warning: {color: 'text-orange-600', icon: AlertTriangle, bg: 'bg-orange-100', label: 'Warning'}, + critical: {color: 'text-red-600', icon: AlertCircle, bg: 'bg-red-100', label: 'Critical'}, +}; + +function getOverallLevel(status: ProjectSecurityMetrics['status']): SecurityLevel { + if (status.violations.length > 0) return 'critical'; + if (status.warnings.length > 0) return 'warning'; + return 'healthy'; +} + export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) { if (isLoading) { return ( @@ -32,42 +34,9 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) { ); } - 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, - ); + const {status, levels, isDisabled} = metrics; + const overallLevel = getOverallLevel(status); + const overallConfig = STATUS_CONFIG[overallLevel]; return (
@@ -75,131 +44,99 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
-
- +
+
Security Overview - {status.isHealthy ? 'Your project is in good standing' : 'Action required to maintain project health'} + {overallLevel === 'healthy' && 'Your project is in good standing'} + {overallLevel === 'warning' && 'Your email health needs attention'} + {overallLevel === 'critical' && 'Action required to maintain project health'}
- {/* Project Disabled Alert */} {isDisabled && ( Project Disabled - This project has been disabled due to critical security violations. Contact support to resolve. + This project has been disabled due to security violations. Contact support to resolve. )} - {/* Violations */} - {status.violations.length > 0 && !isDisabled && ( + {overallLevel === 'critical' && !isDisabled && ( - Critical Violations ({status.violations.length}) + Critical -
    - {status.violations.map((violation, idx) => ( -
  • {violation}
  • - ))} -
+ Your bounce or complaint rates have exceeded acceptable levels. Review your contact lists and sending + practices to avoid project suspension.
)} - {/* Warnings */} - {status.warnings.length > 0 && ( + {overallLevel === 'warning' && ( - Security Warnings ({status.warnings.length}) + Warning -
    - {status.warnings.map((warning, idx) => ( -
  • {warning}
  • - ))} -
+ Your bounce or complaint rates are approaching limits. Review your contact lists and remove invalid + addresses to maintain good standing.
)} - {/* Healthy Status */} - {status.isHealthy && !isDisabled && ( + {overallLevel === 'healthy' && !isDisabled && ( - All security metrics are within acceptable thresholds. Keep up the good work! + All security metrics are within acceptable levels. Keep up the good work! )}
- {/* Bounce Rate Metrics */} + {/* Bounce Metrics */} - Bounce Rate Metrics + Bounce Rate Hard bounces indicate invalid or non-existent email addresses - {/* 7-Day Bounce Rate */} - - - {/* All-Time Bounce Rate */} - - {/* Complaint Rate Metrics */} + {/* Complaint Metrics */} - Complaint Rate Metrics + Complaint Rate Complaints occur when recipients mark emails as spam - {/* 7-Day Complaint Rate */} - - - {/* All-Time Complaint Rate */} - @@ -207,60 +144,25 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) { ); } -interface MetricDisplayProps { +interface HealthMetricProps { 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; + level: SecurityLevel; + detail: string; } -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; +function HealthMetric({label, level, detail}: HealthMetricProps) { + const config = STATUS_CONFIG[level]; + const Icon = config.icon; return ( -
-
-
-

{label}

-

- {count.toLocaleString()} / {total.toLocaleString()} emails - - {rate.toFixed(decimals)}% -

-
-
- - {status.label} -
+
+
+

{label}

+

{detail}

- -
- -
- 0% - Warning: {warningThreshold}% - Critical: {criticalThreshold}% -
+
+ + {config.label}
); diff --git a/apps/web/src/components/SecurityWarningBanner.tsx b/apps/web/src/components/SecurityWarningBanner.tsx index 784b447..00167c2 100644 --- a/apps/web/src/components/SecurityWarningBanner.tsx +++ b/apps/web/src/components/SecurityWarningBanner.tsx @@ -8,7 +8,6 @@ interface SecurityWarningBannerProps { } export function SecurityWarningBanner({status}: SecurityWarningBannerProps) { - // Don't show if no warnings or violations const hasCriticalViolations = status.violations.length > 0; const hasWarnings = status.warnings.length > 0; @@ -16,12 +15,10 @@ export function SecurityWarningBanner({status}: SecurityWarningBannerProps) { return null; } - // Determine severity - critical violations take precedence const variant = hasCriticalViolations ? 'destructive' : 'warning'; const title = hasCriticalViolations - ? 'Critical Security Violations - Immediate Action Required' - : 'Security Warning - Action Required'; - const issues = hasCriticalViolations ? status.violations : status.warnings; + ? 'Critical - Immediate Action Required' + : 'Warning - Action Recommended'; const messageColor = hasCriticalViolations ? 'text-red-800' : 'text-amber-800'; return ( @@ -30,16 +27,10 @@ export function SecurityWarningBanner({status}: SecurityWarningBannerProps) { {title}
-

Your project has exceeded the following security thresholds:

-
    - {issues.map((issue, idx) => ( -
  • {issue}
  • - ))} -
-

+

{hasCriticalViolations - ? 'Your project may be suspended soon. Please review the detailed metrics immediately and take action to improve your email quality.' - : 'High bounce or complaint rates can lead to project suspension. Review the detailed metrics and take action to improve your email quality.'} + ? 'Your bounce or complaint rates have exceeded acceptable levels. Review your contact lists and sending practices to avoid project suspension.' + : 'Your bounce or complaint rates are approaching limits. Review your contact lists and remove invalid addresses to maintain good standing.'}

diff --git a/packages/types/src/security/index.ts b/packages/types/src/security/index.ts index 92b3e7a..f2154c0 100644 --- a/packages/types/src/security/index.ts +++ b/packages/types/src/security/index.ts @@ -14,26 +14,25 @@ export interface SecurityStatus { projectId: string; isHealthy: boolean; shouldDisable: boolean; + twentyFourHour: SecurityRateData; sevenDay: SecurityRateData; allTime: SecurityRateData; + isNewProject: boolean; 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 type SecurityLevel = 'healthy' | 'warning' | 'critical'; + +export interface SecurityMetricLevels { + bounce7Day: SecurityLevel; + bounceAllTime: SecurityLevel; + complaint7Day: SecurityLevel; + complaintAllTime: SecurityLevel; } export interface ProjectSecurityMetrics { status: SecurityStatus; - thresholds: SecurityThresholds; + levels: SecurityMetricLevels; isDisabled: boolean; }