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
+58 -23
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;
updateData.status = EmailStatus.BOUNCED; const isPermanentBounce = bounceType === 'Permanent';
updateData.bouncedAt = now; const isTransientBounce = bounceType === 'Transient';
// Unsubscribe contact on bounce
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
bounceType: body.bounce?.bounceType,
bouncedAt: now.toISOString(),
};
// Send notification about bounce if (isPermanentBounce) {
await NtfyService.notifyEmailBounce( // Hard bounce - counts toward bounce rate and unsubscribes contact
email.project.name, signale.warn(`[WEBHOOK] Permanent bounce received for ${email.contact.email} from ${email.project.name}`);
email.projectId, updateData.status = EmailStatus.BOUNCED;
email.contact.email, updateData.bouncedAt = now;
body.bounce?.bounceType, // Unsubscribe contact on permanent bounce
); await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
bounceType,
bouncedAt: now.toISOString(),
};
// Send notification about permanent bounce
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
} else if (isTransientBounce) {
// Soft bounce (e.g., out-of-office, mailbox full) - don't count toward bounce rate
signale.info(
`[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);
} }