feat(email): add NeverBounce verification and remove Customer.io codepath#1493
Closed
evanjacobson wants to merge 8 commits intomainfrom
Closed
feat(email): add NeverBounce verification and remove Customer.io codepath#1493evanjacobson wants to merge 8 commits intomainfrom
evanjacobson wants to merge 8 commits intomainfrom
Conversation
Add pre-send NeverBounce verification to reduce transactional email bounce rate (~3%). Emails classified as "invalid" or "disposable" are blocked; "valid", "catchall", and "unknown" are allowed through. Fails open if NeverBounce is unavailable. Also removes the unused Customer.io email sending codepath — production has been on Mailgun. Deletes email-customerio.ts, removes EMAIL_PROVIDER config, customerio-node dependency, and CIO provider option from the admin email testing page.
- Add 5s fetch timeout to prevent NeverBounce latency from blocking sends - Validate NeverBounce response status field before trusting result - Report NeverBounce failures to Sentry (not just console.warn) - Return SendResult from send() so callers know if email was blocked - Magic link route now returns 400 when NeverBounce rejects the address - Remove dead CIO template variables from creditsVars - Make subjects the single source of truth for TemplateName
- Org invite route now throws BAD_REQUEST when email is rejected, so the inviter sees an error instead of false success - Billing lifecycle cron removes the idempotency row when NeverBounce blocks a send, allowing retry on the next run instead of permanently skipping the email - Update test mocks to return SendResult instead of undefined
Derive templateNames from subjects instead of duplicating the list. Consolidate identical fixture switch cases via fallthrough.
Without this, a rejected invite leaves a pending invitation in the DB that blocks future invite attempts to the same email address.
Covers all result types (valid, invalid, disposable, catchall, unknown), fail-open behavior (HTTP errors, network errors, non-success API status, missing API key), Sentry reporting, and request parameters.
…ation Admin test emails should go through the same path as production emails so NeverBounce verification is exercised.
14 tasks
| year: String(new Date().getFullYear()), | ||
| }); | ||
| await sendViaMailgun({ to: params.to, subject, html }); | ||
| return { sent: true }; |
Contributor
There was a problem hiding this comment.
WARNING: send() reports success even when Mailgun is unavailable
sendViaMailgun() returns undefined when MAILGUN_API_KEY or MAILGUN_DOMAIN is missing. This branch ignores that sentinel and still returns { sent: true }, so callers like the magic-link route, org invites, billing cron, and the admin test page will all think delivery succeeded even though no email was sent. Please propagate that failure here instead of unconditionally marking the send as successful.
Contributor
Code Review SummaryStatus: 1 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
Other Observations (not in diff)N/A Files Reviewed (17 files)
Reviewed by gpt-5.4-20260305 · 494,615 tokens |
Contributor
Author
evanjacobson
added a commit
that referenced
this pull request
Mar 25, 2026
) ## Summary Adds NeverBounce email verification before sending transactional emails to reduce our ~3% bounce rate. Blocks `invalid` and `disposable` emails; allows `valid`, `catchall`, `unknown` through. Fails open if NeverBounce is unavailable. **How it works:** - New `src/lib/email-neverbounce.ts` calls the NeverBounce single-check API with a 5s timeout - `send()` returns `SendResult` (`{ sent: true } | { sent: false, reason }`) so callers can react - Blocked sends and API failures reported to Sentry **Caller handling:** - Magic link route returns 400 with "Unable to deliver email to this address" - Org invite route expires the invitation row and throws `BAD_REQUEST` - Billing lifecycle cron removes idempotency row on rejection, allowing retry next run - Admin email testing routes through `send()` so NeverBounce is exercised ## Verification ### Automated tests (50 passing) - [x] `npx tsc --noEmit` — no type errors - [x] `pnpm lint` — clean - [x] `email-neverbounce.test.ts` — 11 tests (all result types, fail-open, Sentry reporting, timeout, params) - [x] `magic-link/route.test.ts` — 10 tests - [x] `autoTopUp.test.ts` — 16 tests - [x] `email.test.ts` — 13 tests - [x] Verified via automated agent that this branch preserves all CIO code intact (10/10 checks pass) ### Manual tests (all passing on localhost) - [x] **Invalid email blocked**: `fakeperson@xyznotarealdomainever.comtld` → HTTP 400 - [x] **Disposable email blocked**: `test@mailinator.com` → HTTP 400 - [x] **Valid email allowed**: `support@neverbounce.com` → HTTP 200 - [x] **Real email delivered**: `evan@kilocode.ai` → email received in inbox - [x] **Magic link with invalid email**: returns "Unable to deliver" error - [x] **Org invite with invalid email**: error returned, invitation expired - [x] **NeverBounce not configured (fail-open)**: emails send normally without API key ## Visual Changes N/A — no UI changes in this PR (admin page provider selector is unchanged). ## Reviewer Notes - `NEVERBOUNCE_API_KEY` has been added to Vercel. - This PR is independent of the CIO removal PR (#1498) — they can merge in either order. When combined, they produce the same result as the original PR #1493. - NeverBounce API only supports auth via query parameter — no header auth option available.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds NeverBounce email verification before sending transactional emails to reduce our bounce rate. Removes the unused Customer.io transactional email codepath (production has been on Mailgun).
NeverBounce integration:
src/lib/email-neverbounce.tscalls the NeverBounce single-check API before everysend()invalidanddisposableemails; allowsvalid,catchall,unknownCaller-aware rejection handling:
send()returnsSendResult({ sent: true } | { sent: false, reason }) so callers can reactBAD_REQUESTwhen rejectedsend()so NeverBounce is exercised there tooCustomer.io cleanup:
email-customerio.tsand removedcustomerio-nodedependencyEMAIL_PROVIDERconfig and routing logic —send()always uses Mailgunexternal-services.tsCIO user deletion is preserved (different API keys, used for marketing audience cleanup)Verification
Automated tests (50 passing)
npx tsc --noEmit— no type errorsemail-neverbounce.test.ts— 11 tests (all result types, fail-open, Sentry reporting, timeout, params)magic-link/route.test.ts— 10 testsautoTopUp.test.ts— 16 testsemail.test.ts— 13 testspnpm lint— cleanManual tests (all passing)
fakeperson@xyznotarealdomainever.comvia admin email testing → HTTP 400, "Email blocked by NeverBounce verification. This address is invalid or disposable."you@admin.example.com→ HTTP 400, blocked (NeverBounce returnsinvalid)test@mailinator.com→ HTTP 400, blocked (NeverBounce returnsdisposable)support@neverbounce.com→ HTTP 200, email sent successfullyevan@kilocode.ai→ HTTP 200, email received in inboxinvalid,disposable, andvalidresults via curl against the real APINEVERBOUNCE_API_KEY, emails sent normally without verificationVisual Changes
Reviewer Notes
NEVERBOUNCE_API_KEYhas been added to Vercel. After merge, removeCUSTOMERIO_EMAIL_API_KEYandEMAIL_PROVIDERfrom Vercel (keepCUSTOMERIO_SITE_IDandCUSTOMERIO_API_KEY— used byexternal-services.tsfor marketing user deletion).organization-members-router.test.tsfailure is pre-existing on main (server-only import chain issue) — not caused by this PR.