Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.development.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/auth/magic-link/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 18 additions & 1 deletion src/app/api/auth/magic-link/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/autoTopUp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions src/lib/config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
119 changes: 119 additions & 0 deletions src/lib/email-neverbounce.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof captureMessage>;

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();
});
});
69 changes: 69 additions & 0 deletions src/lib/email-neverbounce.ts
Original file line number Diff line number Diff line change
@@ -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<NeverBounceResult>(['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<boolean> {
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;
}
}
20 changes: 17 additions & 3 deletions src/lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -105,21 +106,32 @@ 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;
templateVars: TemplateVars;
subjectOverride?: string;
};

export async function send(params: SendParams) {
export async function send(params: SendParams): Promise<SendResult> {
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
Expand All @@ -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 = {
Expand Down
25 changes: 24 additions & 1 deletion src/lib/kiloclaw/billing-lifecycle-cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +98 to +99
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?

summary.emails_skipped++;
return false;
}
} catch (error) {
try {
await database
Expand Down
10 changes: 10 additions & 0 deletions src/routers/admin/email-testing-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading