diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index 5982e2b..5ec2503 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -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); diff --git a/apps/api/src/controllers/Projects.ts b/apps/api/src/controllers/Projects.ts index a45d62f..b65c5cd 100644 --- a/apps/api/src/controllers/Projects.ts +++ b/apps/api/src/controllers/Projects.ts @@ -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) { diff --git a/apps/api/src/controllers/Users.ts b/apps/api/src/controllers/Users.ts index 0a18669..09ed62f 100644 --- a/apps/api/src/controllers/Users.ts +++ b/apps/api/src/controllers/Users.ts @@ -1,7 +1,7 @@ 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'; @@ -95,7 +95,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 +127,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 +179,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 +258,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 +308,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 +341,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(); @@ -408,7 +408,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 +534,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 +622,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 +655,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(); @@ -731,7 +731,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(); diff --git a/apps/web/src/components/SecuritySettings.tsx b/apps/web/src/components/SecuritySettings.tsx new file mode 100644 index 0000000..8c82a63 --- /dev/null +++ b/apps/web/src/components/SecuritySettings.tsx @@ -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 ( + + + Security Overview + Monitor your project's email health and reputation + + +

Loading security metrics...

+
+
+ ); + } + + 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 ( +
+ {/* Overall Status Card */} + + +
+
+ +
+
+ Security Overview + + {status.isHealthy ? 'Your project is in good standing' : '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. + + + )} + + {/* Violations */} + {status.violations.length > 0 && !isDisabled && ( + + + Critical Violations ({status.violations.length}) + +
    + {status.violations.map((violation, idx) => ( +
  • {violation}
  • + ))} +
+
+
+ )} + + {/* Warnings */} + {status.warnings.length > 0 && ( + + + Security Warnings ({status.warnings.length}) + +
    + {status.warnings.map((warning, idx) => ( +
  • {warning}
  • + ))} +
+
+
+ )} + + {/* Healthy Status */} + {status.isHealthy && !isDisabled && ( + + + + All security metrics are within acceptable thresholds. Keep up the good work! + + + )} +
+
+ + {/* Bounce Rate Metrics */} + + + Bounce Rate Metrics + Hard bounces indicate invalid or non-existent email addresses + + + {/* 7-Day Bounce Rate */} + + + {/* All-Time Bounce Rate */} + + + + + {/* Complaint Rate Metrics */} + + + Complaint Rate Metrics + Complaints occur when recipients mark emails as spam + + + {/* 7-Day Complaint Rate */} + + + {/* All-Time Complaint Rate */} + + + +
+ ); +} + +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 ( +
+
+
+

{label}

+

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

+
+
+ + {status.label} +
+
+ +
+ +
+ 0% + Warning: {warningThreshold}% + Critical: {criticalThreshold}% +
+
+
+ ); +} diff --git a/apps/web/src/components/SecurityWarningBanner.tsx b/apps/web/src/components/SecurityWarningBanner.tsx new file mode 100644 index 0000000..56de234 --- /dev/null +++ b/apps/web/src/components/SecurityWarningBanner.tsx @@ -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; + } + + const sevenDayBounceRate = status.sevenDay.bounceRate.toFixed(2); + const sevenDayComplaintRate = status.sevenDay.complaintRate.toFixed(3); + + return ( + + + Security Warning - Action Required + +
+

+ Your project has exceeded security thresholds. Current rates: 7-day bounce rate{' '} + {sevenDayBounceRate}%, 7-day complaint rate{' '} + {sevenDayComplaintRate}%. +

+

+ High bounce or complaint rates can lead to project suspension. Review the detailed metrics + and take action to improve your email quality. +

+
+ + + +
+
+ ); +} diff --git a/apps/web/src/lib/hooks/useProjectSecurity.ts b/apps/web/src/lib/hooks/useProjectSecurity.ts new file mode 100644 index 0000000..9adbb23 --- /dev/null +++ b/apps/web/src/lib/hooks/useProjectSecurity.ts @@ -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( + projectId ? `/projects/${projectId}/security` : null, + { + refreshInterval: 120000, // 2 minutes + revalidateOnFocus: false, + }, + ); + + return { + securityMetrics: data?.data, + isLoading, + error, + mutate, + }; +} diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index f4f96c8..5c94de3 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -15,15 +15,18 @@ 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 = [ @@ -68,6 +71,11 @@ export default function Index() { )} + {/* Security Warning Banner */} + {activeProject && !activeProject.disabled && securityMetrics && ( + + )} + {/* Subscription Warning Banner */} {activeProject && !activeProject.disabled && diff --git a/apps/web/src/pages/settings/index.tsx b/apps/web/src/pages/settings/index.tsx index c812771..e7fc62e 100644 --- a/apps/web/src/pages/settings/index.tsx +++ b/apps/web/src/pages/settings/index.tsx @@ -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; @@ -691,6 +705,24 @@ export default function Settings() { /> + {/* Security Tab */} + + {securityMetrics ? ( + + ) : ( + + + Security Overview + + +

+ {isLoadingSecurityMetrics ? 'Loading...' : 'Unable to load security metrics'} +

+
+
+ )} +
+ {/* Domains Tab */} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index cbfb15c..e52410a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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; +}