fix: Improve bounce handling logic to differentiate between permanent and transient bounces

This commit is contained in:
Dries Augustyns
2026-01-09 11:48:20 +01:00
parent b1047590e6
commit 7df43d8553
+47 -12
View File
@@ -152,29 +152,62 @@ export class Webhooks {
break; break;
} }
case 'Bounce': case 'Bounce': {
signale.warn(`[WEBHOOK] Bounce received for ${email.contact.email} from ${email.project.name}`); const bounceType = body.bounce?.bounceType;
const isPermanentBounce = bounceType === 'Permanent';
const isTransientBounce = bounceType === 'Transient';
if (isPermanentBounce) {
// Hard bounce - counts toward bounce rate and unsubscribes contact
signale.warn(`[WEBHOOK] Permanent bounce received for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.BOUNCED; updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now; updateData.bouncedAt = now;
// Unsubscribe contact on bounce // Unsubscribe contact on permanent bounce
await prisma.contact.update({ await prisma.contact.update({
where: {id: email.contactId}, where: {id: email.contactId},
data: {subscribed: false}, data: {subscribed: false},
}); });
eventData = { eventData = {
...baseEventData, ...baseEventData,
bounceType: body.bounce?.bounceType, bounceType,
bouncedAt: now.toISOString(), bouncedAt: now.toISOString(),
}; };
// Send notification about bounce // Send notification about permanent bounce
await NtfyService.notifyEmailBounce( await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
email.project.name, } else if (isTransientBounce) {
email.projectId, // Soft bounce (e.g., out-of-office, mailbox full) - don't count toward bounce rate
email.contact.email, signale.info(
body.bounce?.bounceType, `[WEBHOOK] Transient bounce received for ${email.contact.email} from ${email.project.name} (not counted toward bounce rate)`,
); );
// Don't update email status or unsubscribe contact
// Just track the event for visibility
eventData = {
...baseEventData,
bounceType,
transientBounce: true,
};
} else {
// Unknown bounce type - treat as permanent to be safe
signale.warn(
`[WEBHOOK] Unknown bounce type (${bounceType}) received for ${email.contact.email} from ${email.project.name} - treating as permanent`,
);
updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now;
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
bounceType,
bouncedAt: now.toISOString(),
};
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
}
break; break;
}
case 'Complaint': case 'Complaint':
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`); signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
@@ -208,8 +241,10 @@ export class Webhooks {
// Track event (this will trigger workflows) // Track event (this will trigger workflows)
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData); await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
// Check security limits for bounce and complaint events // Check security limits only for permanent bounces and complaints
if (eventType === 'Bounce' || eventType === 'Complaint') { // Transient bounces (soft bounces) don't count toward bounce rate
const isPermanentBounce = eventType === 'Bounce' && body.bounce?.bounceType === 'Permanent';
if (isPermanentBounce || eventType === 'Complaint') {
await SecurityService.checkAndEnforceSecurityLimits(email.projectId); await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
} }