Skip to content

Fix soft bounce suppression flow#1394

Open
buildwithnidhi wants to merge 1 commit into
mainfrom
feat/email-soft-bounce-handling
Open

Fix soft bounce suppression flow#1394
buildwithnidhi wants to merge 1 commit into
mainfrom
feat/email-soft-bounce-handling

Conversation

@buildwithnidhi

@buildwithnidhi buildwithnidhi commented May 20, 2026

Copy link
Copy Markdown
Collaborator

What this does

This adds proper soft-bounce handling on our side instead of just letting Resend keep retrying and then forgetting about the address.

Right now the issue is basically:

  • we get bounce-related events from Resend
  • but we were not keeping proper recipient-level state for soft bounces
  • so the same bad / unhealthy inbox could keep getting future emails
  • that wastes sends and is bad for sender health over time

This change fixes that flow in the app.

What changed

  • added an EmailBounceLog table to track bounce state per email
  • webhook now handles email.bounced, email.delivery_delayed, email.delivered, and email.complained in a more deterministic way
  • repeated soft/transient failures now suppress the recipient into blockedEmail
  • successful delivery resets the soft-bounce counter and clears soft suppression
  • explicit dead-domain signals like no mx / no such domain / domain does not exist are handled more aggressively
  • queue-time guard added for user-targeted queued mail so blocked recipients do not keep getting enqueued
  • addPayment now passes userId so that targeted path is also covered by the queue-time check
  • webhook processing is idempotent via a stored unique webhook key, so duplicate webhook deliveries do not double-count

Behavior now

  • first transient / mailbox-full style failure: track it, do not block yet
  • second consecutive failure for the same recipient: suppress
  • later successful delivery: reset counter and remove soft-bounce suppression
  • content-rejected / spam-style failures are logged but not treated like a dead inbox problem

Why this was done this way

This keeps the blast radius small.

We did not change the whole email system or wipe user preferences as the main fix.
We kept blockedEmail as the actual enforcement gate and made the webhook + queue layer smarter so repeated bad recipients stop getting targeted.

This also avoids overreacting to ambiguous DNS/provider noise. Only explicit dead-domain signals are treated as dead-domain cases.

Not in this PR

  • no 30-day reprobe / reactivation cron yet
  • no DMARC / SPF work
  • no broader deliverability/content review changes

Those can come later after this is live and we confirm webhook traffic + suppression behavior in prod.

After merge / rollout checklist

  • apply the Prisma schema change
  • confirm Resend webhook secret matches deployed env
  • make sure Resend app webhook stays subscribed to:
    • email.bounced
    • email.delivery_delayed
    • email.delivered
    • email.complained
  • delete the broken Zapier webhook
  • manually verify a replay / fresh webhook event reaches the app successfully

Summary by CodeRabbit

  • Bug Fixes

    • Implemented automatic email blocking for recipients with persistent delivery failures to improve sender reputation and reduce bounce rates.
    • Enhanced webhook event processing to prevent duplicate message handling.
    • Added bounce type classification and suppression logic for improved email deliverability.
  • New Features

    • Introduced comprehensive bounce tracking and logging system to monitor email delivery issues.

Review Change Stack

@vercel

vercel Bot commented May 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
earn Ready Ready Preview May 20, 2026 11:43am

Request Review

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

The PR introduces an internal bounce classification and suppression system for email delivery. It adds database models for bounce tracking (EmailBounceLog), modifies email queue operations to block recipients present in a blockedEmail table, refactors the webhook handler to classify bounces transactionally and update suppression state, and integrates user context into email queue calls.

Changes

Email Bounce Tracking & Suppression System

Layer / File(s) Summary
Schema Models for Bounce Tracking
prisma/schema.prisma
ResendLogs model adds optional emailId and unique webhookKey fields with index. New EmailBounceLog model records bounce metadata (type, diagnostic code, consecutive count, last bounce and suppression timestamps) with indexes on suppressedAt and lastBounceAt.
Email Blocking in Queue Operations
src/features/emails/utils/queueEmail.ts
New isBlockedRecipient(userId?) helper queries the blockedEmail table for a normalized email and returns early if matched. queueEmail imports Prisma and checks this helper before enqueueing, preventing jobs for blocked recipients.
Webhook Handler Bounce Classification & Suppression
src/pages/api/email/webhook/index.ts
Webhook handler refactored to classify bounce fingerprints into categories and apply suppression rules. Bounce events (email.bounced, email.delivery_delayed) increment consecutive bounce counts in emailBounceLog, conditionally set suppressedAt, and upsert blockedEmail reasons. Delivered events reset bounce state. All changes execute inside a Prisma transaction. Duplicate webhooks detected via Prisma constraint violation (P2002) return 200 without reprocessing. Imports updated to remove axios and add transaction/logging helpers.
Payment Submission Email Integration
src/pages/api/sponsor-dashboard/submission/add-payment.ts
queueEmail invocation now passes userId from the submission result, providing user context for email queue operations.

Sequence Diagram(s)

sequenceDiagram
  participant Resend as Resend Webhook
  participant Handler as Webhook Handler
  participant Classify as Bounce Classifier
  participant DB as Prisma Transaction
  participant BounceLog as emailBounceLog
  participant BlockedEmail as blockedEmail
  
  Resend->>Handler: email event + fingerprint
  Handler->>Handler: compute webhookKey from email_id
  Handler->>DB: start transaction
  
  alt email.bounced or email.delivery_delayed
    Handler->>Classify: parse fingerprint, classify bounce type
    Classify-->>Handler: bounce category + diagnostic code
    Handler->>DB: query emailBounceLog for email
    DB-->>Handler: current bounce record
    Handler->>BounceLog: upsert with consecutiveBounces++, lastBounceAt
    Handler->>Handler: check suppression rules
    alt should suppress
      Handler->>BounceLog: set suppressedAt
      Handler->>BlockedEmail: upsert reason (soft/hard bounce)
    end
  else email.delivered
    Handler->>BounceLog: reset consecutiveBounces, suppressedAt
    Handler->>Handler: check if soft-bounce reason
    alt was soft-bounce
      Handler->>BlockedEmail: delete record
    end
  else email.complained
    Handler->>BlockedEmail: delete email settings in transaction
  end
  
  DB->>DB: commit transaction
  alt constraint violation P2002
    Handler-->>Resend: 200 (prevent reprocessing)
  else success
    Handler-->>Resend: 200
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • SuperteamDAO/earn#1257: Adds the optional reason field to the BlockedEmail Prisma model that this PR's webhook handler now maintains via upserts for bounce suppression tracking.

Poem

🐰 Bounces caught, addresses blocked,
Classification rules are stocked!
Transactions roll, state prevails,
Webhook logic never fails—
Email resilience sails! 📧✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix soft bounce suppression flow' directly aligns with the PR's main objective: implementing deterministic handling of soft bounces and related email webhook events to avoid repeated sends to unhealthy inboxes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/email-soft-bounce-handling

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/features/emails/utils/queueEmail.ts (1)

48-48: 💤 Low value

Add explicit return type annotation.

As per coding guidelines, "Declare return types for top-level module functions in TypeScript".

-async function isBlockedRecipient(userId?: string) {
+async function isBlockedRecipient(userId?: string): Promise<boolean> {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/emails/utils/queueEmail.ts` at line 48, The top-level function
isBlockedRecipient lacks an explicit return type; update its signature to
declare the return type as Promise<boolean> (i.e., async function
isBlockedRecipient(userId?: string): Promise<boolean>) so the module follows the
TypeScript guideline to annotate top-level function return types; ensure any
returned values within isBlockedRecipient conform to boolean and adjust any
internal early returns to return Promise-resolved booleans accordingly.
src/pages/api/email/webhook/index.ts (1)

58-81: 💤 Low value

Consider log level for email addresses.

Email addresses are logged at info level (lines 63, 76, 79). While this aids debugging bounce issues, consider whether debug level would be more appropriate for PII, or ensure your log retention/access policies account for this.

Also applies to: 283-311

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/api/email/webhook/index.ts` around lines 58 - 81, Log entries that
include recipient email are currently at info level in deleteEmailSettings;
change those logger.info calls that interpolate recipientEmail to logger.debug
(keep non-PII entries like user.id at info if desired) so PII isn’t emitted at
info level. Update the logger call inside deleteEmailSettings (and any other
functions in this same file that log recipientEmail or other PII, e.g., the
webhook handler that logs bounce/recipient details) to use debug or redact the
email, and ensure logging behavior conforms to your retention/access policy.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/features/emails/utils/queueEmail.ts`:
- Around line 72-74: The log in queueEmail.ts currently includes a PII email
(normalizedEmail) in the logger.info call; update the logger invocation (the
logger.info that mentions `Skipping queued email for blocked recipient`) to
remove the email and only include `userId` and context like
`blockedEmail.reason` (or redact the email variable if you must keep a
placeholder). Locate the `logger.info` call that references `normalizedEmail`,
drop or redact `normalizedEmail`, and keep `userId` and `blockedEmail.reason` so
logs no longer contain PII.

In `@src/pages/api/email/webhook/index.ts`:
- Around line 372-375: The catch block currently logs the full error and returns
the raw error to the client (logger.error(error); return
res.status(400).send(error);) which can leak internals; keep the detailed
logger.error(error) but replace the response body with a generic message (e.g.,
res.status(400).json({ error: 'Invalid request' }) or similar) and avoid sending
the error/stack to the client; update the error handling in the webhook handler
where logger and res are used to return a non-sensitive, user-facing error
message.

---

Nitpick comments:
In `@src/features/emails/utils/queueEmail.ts`:
- Line 48: The top-level function isBlockedRecipient lacks an explicit return
type; update its signature to declare the return type as Promise<boolean> (i.e.,
async function isBlockedRecipient(userId?: string): Promise<boolean>) so the
module follows the TypeScript guideline to annotate top-level function return
types; ensure any returned values within isBlockedRecipient conform to boolean
and adjust any internal early returns to return Promise-resolved booleans
accordingly.

In `@src/pages/api/email/webhook/index.ts`:
- Around line 58-81: Log entries that include recipient email are currently at
info level in deleteEmailSettings; change those logger.info calls that
interpolate recipientEmail to logger.debug (keep non-PII entries like user.id at
info if desired) so PII isn’t emitted at info level. Update the logger call
inside deleteEmailSettings (and any other functions in this same file that log
recipientEmail or other PII, e.g., the webhook handler that logs
bounce/recipient details) to use debug or redact the email, and ensure logging
behavior conforms to your retention/access policy.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e1074e60-68d4-4297-860f-a931204e6af1

📥 Commits

Reviewing files that changed from the base of the PR and between 57506d6 and 9b97cba.

📒 Files selected for processing (4)
  • prisma/schema.prisma
  • src/features/emails/utils/queueEmail.ts
  • src/pages/api/email/webhook/index.ts
  • src/pages/api/sponsor-dashboard/submission/add-payment.ts

Comment on lines +72 to +74
logger.info(
`Skipping queued email for blocked recipient: userId=${userId}, email=${normalizedEmail}, reason=${blockedEmail.reason ?? 'unspecified'}`,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid logging PII (email address).

The log message includes the user's email address, which is personally identifiable information. This creates compliance risk (GDPR/CCPA) and should be avoided in production logs. Use userId alone to identify the blocked recipient.

   logger.info(
-    `Skipping queued email for blocked recipient: userId=${userId}, email=${normalizedEmail}, reason=${blockedEmail.reason ?? 'unspecified'}`,
+    `Skipping queued email for blocked recipient: userId=${userId}, reason=${blockedEmail.reason ?? 'unspecified'}`,
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logger.info(
`Skipping queued email for blocked recipient: userId=${userId}, email=${normalizedEmail}, reason=${blockedEmail.reason ?? 'unspecified'}`,
);
logger.info(
`Skipping queued email for blocked recipient: userId=${userId}, reason=${blockedEmail.reason ?? 'unspecified'}`,
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/emails/utils/queueEmail.ts` around lines 72 - 74, The log in
queueEmail.ts currently includes a PII email (normalizedEmail) in the
logger.info call; update the logger invocation (the logger.info that mentions
`Skipping queued email for blocked recipient`) to remove the email and only
include `userId` and context like `blockedEmail.reason` (or redact the email
variable if you must keep a placeholder). Locate the `logger.info` call that
references `normalizedEmail`, drop or redact `normalizedEmail`, and keep
`userId` and `blockedEmail.reason` so logs no longer contain PII.

Comment on lines 372 to 375
} catch (error) {
logger.error(error);
return res.status(400).send(error);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid leaking internal error details to clients.

Sending the raw error object to the client could expose stack traces or internal implementation details. Return a generic error message instead.

Proposed fix
       } catch (error) {
         logger.error(error);
-        return res.status(400).send(error);
+        return res.status(400).json({ error: 'Webhook processing failed' });
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
logger.error(error);
return res.status(400).send(error);
}
} catch (error) {
logger.error(error);
return res.status(400).json({ error: 'Webhook processing failed' });
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/api/email/webhook/index.ts` around lines 372 - 375, The catch block
currently logs the full error and returns the raw error to the client
(logger.error(error); return res.status(400).send(error);) which can leak
internals; keep the detailed logger.error(error) but replace the response body
with a generic message (e.g., res.status(400).json({ error: 'Invalid request' })
or similar) and avoid sending the error/stack to the client; update the error
handling in the webhook handler where logger and res are used to return a
non-sensitive, user-facing error message.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant