Skip to content

feat: add NeverBounce email verification for transactional emails#1499

Open
evanjacobson wants to merge 5 commits intomainfrom
improvement/neverbounce-email-verification
Open

feat: add NeverBounce email verification for transactional emails#1499
evanjacobson wants to merge 5 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: No Issues Found | Recommendation: Merge

Files Reviewed (2 files)
  • src/app/api/auth/magic-link/route.ts
  • src/lib/kiloclaw/billing-lifecycle-cron.ts

Reviewed by gpt-5.4-20260305 · 145,455 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
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