From 2b65940e73697eec4cb1c8592d7e89ccdd583ab4 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Thu, 4 Dec 2025 19:44:20 +0100 Subject: [PATCH] Only send bounce event every 20 emails --- apps/api/src/services/NtfyService.ts | 74 +++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/api/src/services/NtfyService.ts b/apps/api/src/services/NtfyService.ts index ec33d1e..0aaf2c8 100644 --- a/apps/api/src/services/NtfyService.ts +++ b/apps/api/src/services/NtfyService.ts @@ -276,6 +276,7 @@ export class NtfyService { /** * Notify about email bounce - LOW priority (high volume) + * Rate-limited to only send notification every 20 bounces with per-project breakdown */ public static async notifyEmailBounce( projectName: string, @@ -283,13 +284,72 @@ export class NtfyService { recipientEmail: string, bounceType?: string, ): Promise { - const bounceInfo = bounceType ? ` (${bounceType})` : ''; - await this.send({ - title: 'Email Bounced', - message: `Email to ${recipientEmail} bounced${bounceInfo} in project "${projectName}" (${projectId})`, - priority: NtfyPriority.LOW, - tags: [NtfyTag.WARNING], - }); + // Import redis at runtime to avoid circular dependencies + const {redis} = await import('../database/redis.js'); + + // Use Redis counters to track bounce count globally and per-project + const globalCountKey = `ntfy:bounce:count`; + const projectCountKey = `ntfy:bounce:count:${projectId}`; + const projectListKey = `ntfy:bounce:projects`; + + // Increment global counter + const globalCount = await redis.incr(globalCountKey); + + // Increment project-specific counter + await redis.incr(projectCountKey); + + // Add project to the set of projects with bounces (for tracking) + await redis.sadd(projectListKey, projectId); + + // Set expiry on first increment (24 hour rolling window) + if (globalCount === 1) { + await redis.expire(globalCountKey, 86400); + await redis.expire(projectListKey, 86400); + } + // Always refresh project counter expiry to match global window + await redis.expire(projectCountKey, 86400); + + // Only send notification every 20 bounces + if (globalCount % 20 === 0) { + // Get all projects with bounces + const projectIds = await redis.smembers(projectListKey); + + // Get bounce counts for each project + const projectCounts: Array<{projectId: string; count: number; name: string}> = []; + for (const pid of projectIds) { + const count = await redis.get(`ntfy:bounce:count:${pid}`); + if (count) { + // Fetch project name from database + const {prisma} = await import('../database/prisma.js'); + const project = await prisma.project.findUnique({ + where: {id: pid}, + select: {name: true}, + }); + + projectCounts.push({ + projectId: pid, + count: parseInt(count, 10), + name: project?.name || 'Unknown', + }); + } + } + + // Sort by count descending + projectCounts.sort((a, b) => b.count - a.count); + + // Build breakdown message + const breakdown = projectCounts + .map((p) => ` • ${p.name} (${p.projectId}): ${p.count}`) + .join('\n'); + + const bounceInfo = bounceType ? ` (${bounceType})` : ''; + await this.send({ + title: 'Email Bounces', + message: `20 email bounces detected (total: ${globalCount})\n\nBreakdown by project:\n${breakdown}\n\nLatest: ${recipientEmail}${bounceInfo} in "${projectName}"`, + priority: NtfyPriority.LOW, + tags: [NtfyTag.WARNING], + }); + } } /**