Skip to content

Commit e0e1270

Browse files
Add Zerobounce auth email validation (#15)
2 parents 2023a07 + 2025117 commit e0e1270

File tree

9 files changed

+300
-143
lines changed

9 files changed

+300
-143
lines changed

bun.lock

Lines changed: 133 additions & 136 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@
124124
"@storybook/react": "^8.5.2",
125125
"@storybook/test": "^8.5.2",
126126
"@storybook/theming": "^8.5.2",
127-
"@tailwindcss/postcss": "^4.0.6",
127+
"@tailwindcss/postcss": "^4.0.15",
128128
"@testing-library/jest-dom": "^6.6.3",
129129
"@testing-library/react": "^16.2.0",
130130
"@types/bun": "^1.2.5",
@@ -148,8 +148,7 @@
148148
"server-cli-only": "^0.3.2",
149149
"storybook": "^8.5.2",
150150
"storybook-dark-mode": "^4.0.2",
151-
"tailwind-scrollbar": "^3.1.0",
152-
"tailwindcss": "^4.0.6",
151+
"tailwindcss": "^4.0.15",
153152
"tailwindcss-animate": "^1.0.7",
154153
"tsx": "^4.19.2",
155154
"typescript": "5.7.3",

src/__test__/integration/auth.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
1010
import { redirect } from 'next/navigation'
1111
import { encodedRedirect } from '@/lib/utils/auth'
1212

13+
// Create hoisted mock functions that can be used throughout the file
14+
const { validateEmail, shouldWarnAboutAlternateEmail } = vi.hoisted(() => ({
15+
validateEmail: vi.fn(),
16+
shouldWarnAboutAlternateEmail: vi.fn(),
17+
}))
18+
1319
// Mock console.error to prevent output during tests
1420
const originalConsoleError = console.error
1521
console.error = vi.fn()
@@ -59,6 +65,12 @@ vi.mock('@/lib/utils/auth', () => ({
5965
})),
6066
}))
6167

68+
// Use the hoisted mock functions in the module mock
69+
vi.mock('@/server/auth/validate-email', () => ({
70+
validateEmail,
71+
shouldWarnAboutAlternateEmail,
72+
}))
73+
6274
describe('Auth Actions - Integration Tests', () => {
6375
beforeEach(() => {
6476
vi.resetAllMocks()
@@ -149,6 +161,14 @@ describe('Auth Actions - Integration Tests', () => {
149161
* shows success message
150162
*/
151163
it('should show success message on valid sign-up', async () => {
164+
// Set up mock implementations for this specific test
165+
validateEmail.mockResolvedValue({
166+
valid: true,
167+
data: { status: 'valid', address: '[email protected]' },
168+
})
169+
170+
shouldWarnAboutAlternateEmail.mockResolvedValue(false)
171+
152172
// Setup: Mock Supabase client to return successful sign-up
153173
mockSupabaseClient.auth.signUp.mockResolvedValue({
154174
data: { user: { id: 'new-user-123' } },

src/configs/keys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,5 @@ export const KV_KEYS = {
3131
`user-team-access:${userId}:${teamIdOrSlug}`,
3232
TEAM_SLUG_TO_ID: (slug: string) => `team-slug:${slug}:id`,
3333
TEAM_ID_TO_SLUG: (teamId: string) => `team-id:${teamId}:slug`,
34+
WARNED_ALTERNATE_EMAIL: (email: string) => `warned-alternate-email:${email}`,
3435
}

src/configs/logs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const ERROR_CODES = {
55
URL_REWRITE: 'URL_REWRITE_ERROR',
66
INFRA: 'INFRA_ERROR',
77
GUARD: 'GUARD_ERROR',
8+
EMAIL_VALIDATION: 'EMAIL_VALIDATION_ERROR',
89
} as const
910

1011
export const INFO_CODES = {

src/features/auth/form-message.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,23 @@ export function AuthFormMessage({
2929
>
3030
{'success' in message && (
3131
<Alert variant="contrast1">
32-
<CheckCircle2 className="h-4 w-4" />
32+
<CheckCircle2 className="text-contrast-1 h-4 w-4" />
3333
<AlertDescription>
3434
{decodeURIComponent(message.success!)}
3535
</AlertDescription>
3636
</Alert>
3737
)}
3838
{'error' in message && (
3939
<Alert variant="error">
40-
<AlertCircle className="h-4 w-4" />
40+
<AlertCircle className="text-error h-4 w-4" />
4141
<AlertDescription>
4242
{decodeURIComponent(message.error!)}
4343
</AlertDescription>
4444
</Alert>
4545
)}
4646
{'message' in message && (
4747
<Alert variant="contrast2">
48-
<Info className="h-4 w-4" />
48+
<Info className="text-contrast-2 h-4 w-4" />
4949
<AlertDescription>
5050
{decodeURIComponent(message.message!)}
5151
</AlertDescription>

src/server/auth/auth-actions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { actionClient } from '@/lib/clients/action'
1010
import { returnServerError } from '@/lib/utils/action'
1111
import { z } from 'zod'
1212
import { zfd } from 'zod-form-data'
13+
import {
14+
shouldWarnAboutAlternateEmail,
15+
validateEmail,
16+
} from '@/server/auth/validate-email'
1317

1418
export const signInWithOAuthAction = actionClient
1519
.schema(
@@ -78,11 +82,32 @@ export const signUpAction = actionClient
7882
const supabase = await createClient()
7983
const origin = (await headers()).get('origin') || ''
8084

85+
const validationResult = await validateEmail(email)
86+
87+
if (validationResult?.data) {
88+
if (!validationResult.valid) {
89+
return returnServerError(
90+
'Please use a valid email address - your company email works best'
91+
)
92+
}
93+
94+
if (await shouldWarnAboutAlternateEmail(validationResult.data)) {
95+
return returnServerError(
96+
'Is this a secondary email? Use your primary email for fast access'
97+
)
98+
}
99+
}
100+
81101
const { error } = await supabase.auth.signUp({
82102
email,
83103
password,
84104
options: {
85105
emailRedirectTo: `${origin}${AUTH_URLS.CALLBACK}${returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''}`,
106+
data: validationResult?.data
107+
? {
108+
email_validation: validationResult?.data,
109+
}
110+
: undefined,
86111
},
87112
})
88113

src/server/auth/validate-email.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { KV_KEYS } from '@/configs/keys'
2+
import { ERROR_CODES } from '@/configs/logs'
3+
import { kv } from '@vercel/kv'
4+
5+
/**
6+
* Response type from the ZeroBounce email validation API
7+
*/
8+
export type EmailValidationResponse = {
9+
address: string
10+
status: string
11+
sub_status: string
12+
free_email: boolean
13+
account: string
14+
domain: string
15+
mx_found: boolean
16+
did_you_mean: string | null
17+
domain_age_days: string | null
18+
active_in_days: string | null
19+
smtp_provider: string | null
20+
mx_record: string | null
21+
firstname: string | null
22+
lastname: string | null
23+
gender: string | null
24+
country: string | null
25+
region: string | null
26+
city: string | null
27+
zipcode: string | null
28+
processed_at: string
29+
}
30+
31+
/**
32+
* Validates an email address using the ZeroBounce API
33+
*
34+
* This function checks if an email is deliverable and safe to use by querying
35+
* the ZeroBounce validation service. It handles various email statuses including
36+
* invalid addresses, spam traps, and abusive accounts.
37+
*
38+
* @param email - The email address to validate
39+
* @returns An object containing validation result and response data, or null
40+
* - Object with `{ valid: boolean, data: EmailValidationResponse }` when validation succeeds
41+
* - `null` if validation couldn't be performed (API key missing or error occurred)
42+
* This allows for graceful degradation when email validation is unavailable
43+
*
44+
* @example
45+
* const result = await validateEmail("[email protected]");
46+
* if (result === null) {
47+
* // Validation service unavailable
48+
* } else if (result.valid) {
49+
* // Email is valid
50+
* } else {
51+
* // Email is invalid
52+
* }
53+
*/
54+
export async function validateEmail(
55+
email: string
56+
): Promise<{ valid: boolean; data: EmailValidationResponse } | null> {
57+
if (!process.env.ZEROBOUNCE_API_KEY) {
58+
return null
59+
}
60+
61+
try {
62+
const response = await fetch(
63+
`https://api.zerobounce.net/v2/validate?api_key=${process.env.ZEROBOUNCE_API_KEY}&email=${email}&ip_address=`
64+
)
65+
// Parse the JSON response from the ZeroBounce API
66+
const responseData = await response.json()
67+
68+
// Convert the mx_found string value to a boolean if it's 'true' or 'false'
69+
// Otherwise keep the original value (could be null or another value)
70+
const data = {
71+
...responseData,
72+
mx_found:
73+
responseData.mx_found === 'true'
74+
? true
75+
: responseData.mx_found === 'false'
76+
? false
77+
: responseData.mx_found,
78+
} as EmailValidationResponse
79+
80+
switch (data.status) {
81+
case 'invalid':
82+
case 'spamtrap':
83+
case 'abuse':
84+
case 'do_not_mail':
85+
return { valid: false, data }
86+
default:
87+
return { valid: true, data }
88+
}
89+
} catch (error) {
90+
console.error(ERROR_CODES.EMAIL_VALIDATION, error)
91+
return null
92+
}
93+
}
94+
95+
export const shouldWarnAboutAlternateEmail = async (
96+
validationResult: EmailValidationResponse
97+
): Promise<boolean> => {
98+
if (validationResult.sub_status === 'alternate') {
99+
const warnedAlternateEmail = await kv.get(
100+
KV_KEYS.WARNED_ALTERNATE_EMAIL(validationResult.address)
101+
)
102+
103+
if (!warnedAlternateEmail) {
104+
await kv.set(
105+
KV_KEYS.WARNED_ALTERNATE_EMAIL(validationResult.address),
106+
true
107+
)
108+
109+
return true
110+
}
111+
}
112+
113+
return false
114+
}

src/ui/primitives/alert.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const AlertDescription = React.forwardRef<
5858
>(({ className, ...props }, ref) => (
5959
<div
6060
ref={ref}
61-
className={cn('text-fg-500 text-sm [&_p]:leading-relaxed', className)}
61+
className={cn('text-fg-300 text-sm [&_p]:leading-relaxed', className)}
6262
{...props}
6363
/>
6464
))

0 commit comments

Comments
 (0)