Skip to content

feat: add NeverBounce email verification for transactional emails#1499

Merged
evanjacobson merged 6 commits intomainfrom
improvement/neverbounce-email-verification
Mar 25, 2026
Merged

feat: add NeverBounce email verification for transactional emails#1499
evanjacobson merged 6 commits intomainfrom
improvement/neverbounce-email-verification

Conversation

@evanjacobson
Copy link
Contributor

@evanjacobson evanjacobson commented Mar 24, 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)

  • npx tsc --noEmit — no type errors
  • pnpm lint — clean
  • email-neverbounce.test.ts — 11 tests (all result types, fail-open, Sentry reporting, timeout, params)
  • magic-link/route.test.ts — 10 tests
  • autoTopUp.test.ts — 16 tests
  • email.test.ts — 13 tests
  • Verified via automated agent that this branch preserves all CIO code intact (10/10 checks pass)

Manual tests (all passing on localhost)

  • Invalid email blocked: fakeperson@xyznotarealdomainever.comtld → HTTP 400
  • Disposable email blocked: test@mailinator.com → HTTP 400
  • Valid email allowed: support@neverbounce.com → HTTP 200
  • Real email delivered: evan@kilocode.ai → email received in inbox
  • Magic link with invalid email: returns "Unable to deliver" error
  • Org invite with invalid email: error returned, invitation expired
  • 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

Adds pre-send NeverBounce verification to reduce ~3% bounce rate on
transactional emails. Blocks invalid and disposable emails; allows
valid, catchall, and unknown through. Fails open if NeverBounce is
unavailable.

Callers receive SendResult so they can react to blocked emails:
- Magic link route returns 400 with user-facing error
- Org invite expires invitation row and throws BAD_REQUEST
- Billing lifecycle cron removes idempotency row for retry
- Admin email testing routes through send() for verification
@kilo-code-bot
Copy link
Contributor

kilo-code-bot bot commented Mar 24, 2026

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
src/routers/organizations/organization-members-router.ts 228 Provider misconfiguration is still reported as a bad invite address

Fix these issues in Kilo Cloud

Other Observations (not in diff)

Previously raised issues in src/lib/email.ts, src/app/api/auth/magic-link/route.ts, src/lib/kiloclaw/billing-lifecycle-cron.ts, and src/routers/admin/email-testing-router.ts are fixed in the current head.

Files Reviewed (12 files)
  • .env.development.local.example
  • src/app/api/auth/magic-link/route.test.ts
  • src/app/api/auth/magic-link/route.ts
  • src/lib/autoTopUp.test.ts
  • src/lib/config.server.ts
  • src/lib/email-neverbounce.test.ts
  • src/lib/email-neverbounce.ts
  • src/lib/email.ts
  • src/lib/kiloclaw/billing-lifecycle-cron.ts
  • src/routers/admin/email-testing-router.ts
  • src/routers/organizations/organization-members-router.test.ts
  • src/routers/organizations/organization-members-router.ts - 1 issue

Reviewed by gpt-5.4-20260305 · 2,444,342 tokens

- Admin sendTest restores original provider-specific routing (respects
  selected provider) with NeverBounce check added before dispatch
- send() now checks provider return value — returns provider_not_configured
  when credentials are missing instead of falsely claiming sent: true
- SendResult reason union extended with 'provider_not_configured'
Copy link
Contributor Author

Choose a reason for hiding this comment

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

most of this will be filtered out after #1356 merges

- Magic link route: neverbounce_rejected → 400 (user's email is bad),
  provider_not_configured → 500 (server issue, don't blame the user)
- Billing cron: only deletes idempotency row for provider_not_configured
  (transient). Keeps the row for neverbounce_rejected (terminal) to
  avoid re-verifying the same invalid address every sweep.
execution_time: number;
};

const BLOCKED_RESULTS = new Set<NeverBounceResult>(['invalid', 'disposable']);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

note that 'disposable' was not explicitly included in the spec, but is included here. 'disposable' emails are services such as tempmail, guerilla mail, etc.

@evanjacobson evanjacobson enabled auto-merge March 24, 2026 23:12
acceptInviteUrl,
});

if (!emailResult.sent) {
Copy link
Contributor

Choose a reason for hiding this comment

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

WARNING: Provider misconfiguration is still reported as a bad invite address

sendOrganizationInviteEmail() now distinguishes neverbounce_rejected from provider_not_configured, but this branch collapses both cases into the same BAD_REQUEST / "use a different email" response. If email delivery is broken in preview or staging because the provider credentials are missing, organization owners will be told the invitee's address is invalid even though the failure is operational.

Comment on lines +98 to +99
// For neverbounce_rejected the address is permanently invalid — keep the
// idempotency row so we don't re-verify on every sweep.
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment seems out of place? The code below it doesn't relate to it?

@evanjacobson evanjacobson merged commit 72aeda1 into main Mar 25, 2026
19 checks passed
@evanjacobson evanjacobson deleted the improvement/neverbounce-email-verification branch March 25, 2026 14:09
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.

2 participants