diff --git a/.env.development.local.example b/.env.development.local.example index 0fcaa9cb9..1f44d28d7 100644 --- a/.env.development.local.example +++ b/.env.development.local.example @@ -2,6 +2,7 @@ # EMAIL_PROVIDER=customerio # MAILGUN_API_KEY=key-... # MAILGUN_DOMAIN=app.kilocode.ai +# NEVERBOUNCE_API_KEY=... # Stripe integration STRIPE_WEBHOOK_SECRET="...extract this from: stripe listen --forward-to http://localhost:3000/api/stripe/webhook" diff --git a/src/app/api/auth/magic-link/route.test.ts b/src/app/api/auth/magic-link/route.test.ts index f2a0b1802..14a2a9096 100644 --- a/src/app/api/auth/magic-link/route.test.ts +++ b/src/app/api/auth/magic-link/route.test.ts @@ -54,7 +54,7 @@ describe('POST /api/auth/magic-link', () => { // Default: Magic link creation succeeds mockCreateMagicLinkToken.mockResolvedValue(mockMagicLinkToken); - mockSendMagicLinkEmail.mockResolvedValue(undefined); + mockSendMagicLinkEmail.mockResolvedValue({ sent: true }); // Default: User does not exist (new user signup) mockFindUserByEmail.mockResolvedValue(undefined); diff --git a/src/app/api/auth/magic-link/route.ts b/src/app/api/auth/magic-link/route.ts index 33366bd45..2393cc891 100644 --- a/src/app/api/auth/magic-link/route.ts +++ b/src/app/api/auth/magic-link/route.ts @@ -56,7 +56,24 @@ export async function POST(request: NextRequest) { } const magicLink = await createMagicLinkToken(email); - await sendMagicLinkEmail(magicLink, callbackUrl); + const result = await sendMagicLinkEmail(magicLink, callbackUrl); + + if (!result.sent) { + if (result.reason === 'neverbounce_rejected') { + return NextResponse.json( + { + success: false, + error: 'Unable to deliver email to this address. Please use a different email.', + }, + { status: 400 } + ); + } + // provider_not_configured — internal issue, don't blame the user's email + return NextResponse.json( + { success: false, error: 'An internal error occurred. Please try again later.' }, + { status: 500 } + ); + } return NextResponse.json({ success: true, diff --git a/src/lib/autoTopUp.test.ts b/src/lib/autoTopUp.test.ts index 6da584350..652645288 100644 --- a/src/lib/autoTopUp.test.ts +++ b/src/lib/autoTopUp.test.ts @@ -29,7 +29,7 @@ const toMicrodollars = (dollars: number) => dollars * 1_000_000; // Mock email sending to avoid CustomerIO errors in tests jest.mock('@/lib/email', () => ({ - sendAutoTopUpFailedEmail: jest.fn().mockResolvedValue(undefined), + sendAutoTopUpFailedEmail: jest.fn().mockResolvedValue({ sent: true }), })); jest.mock('@/lib/stripe-client', () => { diff --git a/src/lib/config.server.ts b/src/lib/config.server.ts index 9cdf3c467..2dd4d8bd0 100644 --- a/src/lib/config.server.ts +++ b/src/lib/config.server.ts @@ -11,6 +11,7 @@ export const NEXTAUTH_URL = APP_URL; export const CUSTOMERIO_EMAIL_API_KEY = getEnvVariable('CUSTOMERIO_EMAIL_API_KEY'); export const MAILGUN_API_KEY = getEnvVariable('MAILGUN_API_KEY'); export const MAILGUN_DOMAIN = getEnvVariable('MAILGUN_DOMAIN'); +export const NEVERBOUNCE_API_KEY = getEnvVariable('NEVERBOUNCE_API_KEY'); // Which email backend to use: 'customerio' (default) or 'mailgun' const emailProviderRaw = getEnvVariable('EMAIL_PROVIDER') || 'customerio'; if (emailProviderRaw !== 'customerio' && emailProviderRaw !== 'mailgun') { diff --git a/src/lib/email-neverbounce.test.ts b/src/lib/email-neverbounce.test.ts new file mode 100644 index 000000000..780cd9389 --- /dev/null +++ b/src/lib/email-neverbounce.test.ts @@ -0,0 +1,119 @@ +import { captureMessage } from '@sentry/nextjs'; +import { verifyEmail } from '@/lib/email-neverbounce'; + +jest.mock('@sentry/nextjs', () => ({ + captureMessage: jest.fn(), +})); + +const mockCaptureMessage = captureMessage as jest.MockedFunction; + +let mockApiKey: string | undefined = 'test-api-key'; + +jest.mock('@/lib/config.server', () => ({ + get NEVERBOUNCE_API_KEY() { + return mockApiKey; + }, +})); + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +function mockNeverBounceResponse(result: string, status = 'success') { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + status, + result, + flags: ['has_dns', 'has_dns_mx'], + suggested_correction: '', + execution_time: 100, + }), + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockApiKey = 'test-api-key'; +}); + +describe('verifyEmail', () => { + it('returns true when API key is not configured', async () => { + mockApiKey = undefined; + expect(await verifyEmail('test@example.com')).toBe(true); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns true for valid emails', async () => { + mockNeverBounceResponse('valid'); + expect(await verifyEmail('good@example.com')).toBe(true); + expect(mockCaptureMessage).not.toHaveBeenCalled(); + }); + + it('returns false for invalid emails and reports to Sentry', async () => { + mockNeverBounceResponse('invalid'); + expect(await verifyEmail('bad@example.com')).toBe(false); + expect(mockCaptureMessage).toHaveBeenCalledWith( + 'Blocked email send to invalid address', + expect.objectContaining({ + level: 'info', + tags: { source: 'neverbounce', result: 'invalid' }, + }) + ); + }); + + it('returns false for disposable emails', async () => { + mockNeverBounceResponse('disposable'); + expect(await verifyEmail('temp@mailinator.com')).toBe(false); + }); + + it('returns true for catchall emails', async () => { + mockNeverBounceResponse('catchall'); + expect(await verifyEmail('anyone@catchall.com')).toBe(true); + }); + + it('returns true for unknown emails', async () => { + mockNeverBounceResponse('unknown'); + expect(await verifyEmail('mystery@example.com')).toBe(true); + }); + + it('returns true on HTTP error (fail-open)', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + expect(await verifyEmail('test@example.com')).toBe(true); + }); + + it('returns true on network error (fail-open) and reports to Sentry', async () => { + mockFetch.mockRejectedValue(new Error('fetch failed')); + expect(await verifyEmail('test@example.com')).toBe(true); + expect(mockCaptureMessage).toHaveBeenCalledWith( + 'NeverBounce verification check failed', + expect.objectContaining({ level: 'warning' }) + ); + }); + + it('returns true on non-success API status and reports to Sentry', async () => { + mockNeverBounceResponse('valid', 'auth_failure'); + expect(await verifyEmail('test@example.com')).toBe(true); + expect(mockCaptureMessage).toHaveBeenCalledWith( + 'NeverBounce API returned non-success status: auth_failure', + expect.objectContaining({ level: 'warning' }) + ); + }); + + it('passes email and API key as query parameters', async () => { + mockNeverBounceResponse('valid'); + await verifyEmail('user@test.com'); + const calledUrl = new URL(mockFetch.mock.calls[0][0]); + expect(calledUrl.origin + calledUrl.pathname).toBe( + 'https://api.neverbounce.com/v4.2/single/check' + ); + expect(calledUrl.searchParams.get('key')).toBe('test-api-key'); + expect(calledUrl.searchParams.get('email')).toBe('user@test.com'); + }); + + it('sets a 5-second timeout on the fetch call', async () => { + mockNeverBounceResponse('valid'); + await verifyEmail('user@test.com'); + const fetchOptions = mockFetch.mock.calls[0][1]; + expect(fetchOptions.signal).toBeDefined(); + }); +}); diff --git a/src/lib/email-neverbounce.ts b/src/lib/email-neverbounce.ts new file mode 100644 index 000000000..2e522431f --- /dev/null +++ b/src/lib/email-neverbounce.ts @@ -0,0 +1,69 @@ +import { NEVERBOUNCE_API_KEY } from '@/lib/config.server'; +import { captureMessage } from '@sentry/nextjs'; + +type NeverBounceResult = 'valid' | 'invalid' | 'disposable' | 'catchall' | 'unknown'; + +type NeverBounceResponse = { + status: string; + result: NeverBounceResult; + flags: string[]; + suggested_correction: string; + execution_time: number; +}; + +const BLOCKED_RESULTS = new Set(['invalid', 'disposable']); + +/** + * Returns true if the email is safe to send to, false if it should be skipped. + * If NeverBounce is not configured or the check fails, defaults to allowing the send. + */ +export async function verifyEmail(email: string): Promise { + if (!NEVERBOUNCE_API_KEY) { + return true; + } + + try { + const url = new URL('https://api.neverbounce.com/v4.2/single/check'); + url.searchParams.set('key', NEVERBOUNCE_API_KEY); + url.searchParams.set('email', email); + + const response = await fetch(url, { signal: AbortSignal.timeout(5_000) }); + if (!response.ok) { + console.warn(`[neverbounce] API returned ${response.status} for ${email}, allowing send`); + return true; + } + + const data: NeverBounceResponse = await response.json(); + + if (data.status !== 'success') { + console.warn(`[neverbounce] API returned status=${data.status} for ${email}, allowing send`); + captureMessage(`NeverBounce API returned non-success status: ${data.status}`, { + level: 'warning', + tags: { source: 'neverbounce' }, + extra: { email, status: data.status }, + }); + return true; + } + + if (BLOCKED_RESULTS.has(data.result)) { + captureMessage(`Blocked email send to ${data.result} address`, { + level: 'info', + tags: { source: 'neverbounce', result: data.result }, + extra: { email, flags: data.flags, suggested_correction: data.suggested_correction }, + }); + console.log(`[neverbounce] Blocked send to ${email}: result=${data.result}`); + return false; + } + + return true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn(`[neverbounce] Check failed for ${email}: ${errorMessage}, allowing send`); + captureMessage('NeverBounce verification check failed', { + level: 'warning', + tags: { source: 'neverbounce' }, + extra: { email, error: errorMessage }, + }); + return true; + } +} diff --git a/src/lib/email.ts b/src/lib/email.ts index 6bec571a0..1174706fb 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -5,6 +5,7 @@ import { getMagicLinkUrl, type MagicLinkTokenWithPlaintext } from '@/lib/auth/ma import { EMAIL_PROVIDER, NEXTAUTH_URL } from '@/lib/config.server'; import { sendViaCustomerIo } from '@/lib/email-customerio'; import { sendViaMailgun } from '@/lib/email-mailgun'; +import { verifyEmail } from '@/lib/email-neverbounce'; export const templates = { orgSubscription: '10', @@ -105,6 +106,10 @@ export function creditsVars(monthlyCreditsUsd: number): TemplateVars { }; } +export type SendResult = + | { sent: true } + | { sent: false; reason: 'neverbounce_rejected' | 'provider_not_configured' }; + type SendParams = { to: string; templateName: TemplateName; @@ -112,14 +117,21 @@ type SendParams = { subjectOverride?: string; }; -export async function send(params: SendParams) { +export async function send(params: SendParams): Promise { + const isSafeToSend = await verifyEmail(params.to); + if (!isSafeToSend) { + return { sent: false, reason: 'neverbounce_rejected' }; + } + if (EMAIL_PROVIDER === 'mailgun') { const subject = params.subjectOverride ?? subjects[params.templateName]; const html = renderTemplate(params.templateName, { ...params.templateVars, year: String(new Date().getFullYear()), }); - return sendViaMailgun({ to: params.to, subject, html }); + const result = await sendViaMailgun({ to: params.to, subject, html }); + if (!result) return { sent: false, reason: 'provider_not_configured' as const }; + return { sent: true }; } // Customer.io handles its own rendering; pass raw string values. // If a subjectOverride is provided, include it as `subject` in message_data @@ -130,13 +142,15 @@ export async function send(params: SendParams) { if (params.subjectOverride) { messageData.subject = params.subjectOverride; } - return sendViaCustomerIo({ + const result = await sendViaCustomerIo({ transactional_message_id: templates[params.templateName], to: params.to, message_data: messageData, identifiers: { email: params.to }, reply_to: 'hi@kilocode.ai', }); + if (!result) return { sent: false, reason: 'provider_not_configured' as const }; + return { sent: true }; } type OrganizationInviteEmailData = { diff --git a/src/lib/kiloclaw/billing-lifecycle-cron.ts b/src/lib/kiloclaw/billing-lifecycle-cron.ts index a4437952b..72de1cb50 100644 --- a/src/lib/kiloclaw/billing-lifecycle-cron.ts +++ b/src/lib/kiloclaw/billing-lifecycle-cron.ts @@ -76,7 +76,30 @@ async function trySendEmail( return false; } try { - await sendEmail({ to: userEmail, templateName, templateVars, subjectOverride }); + const emailResult = await sendEmail({ + to: userEmail, + templateName, + templateVars, + subjectOverride, + }); + + if (!emailResult.sent) { + if (emailResult.reason === 'provider_not_configured') { + // Transient — credentials may be added later; remove idempotency guard so the next cron run can retry + await database + .delete(kiloclaw_email_log) + .where( + and( + eq(kiloclaw_email_log.user_id, userId), + eq(kiloclaw_email_log.email_type, emailType) + ) + ); + } + // For neverbounce_rejected the address is permanently invalid — keep the + // idempotency row so we don't re-verify on every sweep. + summary.emails_skipped++; + return false; + } } catch (error) { try { await database diff --git a/src/routers/admin/email-testing-router.ts b/src/routers/admin/email-testing-router.ts index b166893c7..9593a95c0 100644 --- a/src/routers/admin/email-testing-router.ts +++ b/src/routers/admin/email-testing-router.ts @@ -3,6 +3,7 @@ import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; import { NEXTAUTH_URL } from '@/lib/config.server'; import { sendViaCustomerIo } from '@/lib/email-customerio'; import { sendViaMailgun } from '@/lib/email-mailgun'; +import { verifyEmail } from '@/lib/email-neverbounce'; import { templates, subjects, @@ -197,6 +198,15 @@ export const emailTestingRouter = createTRPCRouter({ }) ) .mutation(async ({ input }) => { + const isSafeToSend = await verifyEmail(input.recipient); + if (!isSafeToSend) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'Email blocked by NeverBounce verification. This address is invalid or disposable.', + }); + } + const vars = fixtureTemplateVars(input.template); if (input.provider === 'customerio') { diff --git a/src/routers/organizations/organization-members-router.test.ts b/src/routers/organizations/organization-members-router.test.ts index 14add39e0..6a5fd1d77 100644 --- a/src/routers/organizations/organization-members-router.test.ts +++ b/src/routers/organizations/organization-members-router.test.ts @@ -5,7 +5,7 @@ import type { User, Organization } from '@kilocode/db/schema'; // Mock the email service to prevent actual API calls during tests jest.mock('@/lib/email', () => ({ - sendOrganizationInviteEmail: jest.fn().mockResolvedValue(undefined), + sendOrganizationInviteEmail: jest.fn().mockResolvedValue({ sent: true }), })); // Test users and organization will be created dynamically diff --git a/src/routers/organizations/organization-members-router.ts b/src/routers/organizations/organization-members-router.ts index d568e5332..7c0e00829 100644 --- a/src/routers/organizations/organization-members-router.ts +++ b/src/routers/organizations/organization-members-router.ts @@ -218,13 +218,26 @@ export const organizationsMembersRouter = createTRPCRouter({ } const acceptInviteUrl = getAcceptInviteUrl(invitation.token); - await sendOrganizationInviteEmail({ + const emailResult = await sendOrganizationInviteEmail({ to: email, organizationName: organization.name, inviterName: user.google_user_name, acceptInviteUrl, }); + if (!emailResult.sent) { + // Expire the invitation so it doesn't block future invites to the same email + await db + .update(organization_invitations) + .set({ expires_at: sql`NOW()` }) + .where(eq(organization_invitations.id, invitation.id)); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'Unable to deliver the invitation email to this address. Please use a different email.', + }); + } + await createAuditLog({ action: 'organization.user.send_invite', actor_email: user.google_user_email,