mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(auth): add Turnstile captcha + harmony disposable email blocking (#3699)
* feat(turnstile): conditionally added CF turnstile to signup * feat(auth): add execute-on-submit Turnstile, conditional harmony, and feature flag - Switch Turnstile to execution: 'execute' mode so challenge runs on form submit (fresh token every time, no expiry issues) - Make emailHarmony conditional via SIGNUP_EMAIL_VALIDATION_ENABLED feature flag so self-hosted users can opt out - Add isSignupEmailValidationEnabled to feature-flags.ts following existing pattern - Add better-auth-harmony to Next.js transpilePackages (required for validator.js ESM compatibility) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(validation): remove dead validateEmail and checkMXRecord Server-side disposable email blocking is now handled by better-auth-harmony. The async validateEmail (with MX check) had no remaining callers. Only quickValidateEmail remains for client-side form feedback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): add 15s timeout to Turnstile captcha promise Prevents form from hanging indefinitely if Turnstile never fires onSuccess/onError (e.g. script fails to load, network drop). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(helm): add Turnstile and harmony env vars to values.yaml Adds TURNSTILE_SECRET_KEY, NEXT_PUBLIC_TURNSTILE_SITE_KEY, and SIGNUP_EMAIL_VALIDATION_ENABLED to the helm chart so self-hosted deployments can configure captcha and disposable email blocking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): reject captcha promise on token expiry onExpire now rejects the pending promise so the form doesn't hang if the Turnstile token expires mid-challenge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(login): replace useEffect keydown listener with form onSubmit The forgot-password modal used a global window keydown listener in a useEffect to handle Enter key — a "you might not need an effect" anti-pattern with a stale closure risk. Replaced with a native <form onSubmit> wrapper which handles Enter natively, eliminating the useEffect, the global listener, and the stale closure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): clear dangling timeout after captcha promise settles Use .finally(() => clearTimeout(timeoutId)) to clean up the 15s timeout timer when the captcha resolves before the deadline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(auth): use getResponsePromise() for Turnstile token retrieval Replace the manual Promise + refs + timeout pattern with the documented getResponsePromise(timeout) API from @marsidev/react-turnstile. This eliminates captchaToken state, captchaResolveRef, captchaRejectRef, and all callback wiring on the Turnstile component. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): show captcha errors as form-level message, not password error Captcha failures were misleadingly displayed under the password field. Added a dedicated formError state that renders above the submit button, making it clear the issue is with verification, not the password. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
224ff5dacc
commit
4a34ac3015
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -86,6 +87,9 @@ export default function LoginPage({
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const callbackUrlParam = searchParams?.get('callbackUrl')
|
||||
@@ -115,19 +119,6 @@ export default function LoginPage({
|
||||
: null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && forgotPasswordOpen) {
|
||||
handleForgotPassword()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [forgotPasswordEmail, forgotPasswordOpen])
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newEmail = e.target.value
|
||||
setEmail(newEmail)
|
||||
@@ -178,6 +169,21 @@ export default function LoginPage({
|
||||
const safeCallbackUrl = callbackUrl
|
||||
let errorHandled = false
|
||||
|
||||
// Execute Turnstile challenge on submit and get a fresh token
|
||||
let token: string | undefined
|
||||
if (turnstileSiteKey && turnstileRef.current) {
|
||||
try {
|
||||
turnstileRef.current.reset()
|
||||
turnstileRef.current.execute()
|
||||
token = await turnstileRef.current.getResponsePromise(15_000)
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setFormError(null)
|
||||
const result = await client.signIn.email(
|
||||
{
|
||||
email,
|
||||
@@ -185,6 +191,11 @@ export default function LoginPage({
|
||||
callbackURL: safeCallbackUrl,
|
||||
},
|
||||
{
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
...(token ? { 'x-captcha-response': token } : {}),
|
||||
},
|
||||
},
|
||||
onError: (ctx) => {
|
||||
logger.error('Login error:', ctx.error)
|
||||
|
||||
@@ -460,6 +471,20 @@ export default function LoginPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ size: 'invisible', execution: 'execute' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<div className='text-red-400 text-xs'>
|
||||
<p>{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isLoading}
|
||||
@@ -540,45 +565,51 @@ export default function LoginPage({
|
||||
<ModalContent className='dark' size='sm'>
|
||||
<ModalHeader>Reset Password</ModalHeader>
|
||||
<ModalBody>
|
||||
<ModalDescription className='mb-4 text-[var(--text-muted)] text-sm'>
|
||||
Enter your email address and we'll send you a link to reset your password if your
|
||||
account exists.
|
||||
</ModalDescription>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='reset-email'>Email</Label>
|
||||
<Input
|
||||
id='reset-email'
|
||||
value={forgotPasswordEmail}
|
||||
onChange={(e) => setForgotPasswordEmail(e.target.value)}
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
type='email'
|
||||
className={cn(
|
||||
resetStatus.type === 'error' && 'border-red-500 focus:border-red-500'
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleForgotPassword()
|
||||
}}
|
||||
>
|
||||
<ModalDescription className='mb-4 text-[var(--text-muted)] text-sm'>
|
||||
Enter your email address and we'll send you a link to reset your password if your
|
||||
account exists.
|
||||
</ModalDescription>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='reset-email'>Email</Label>
|
||||
<Input
|
||||
id='reset-email'
|
||||
value={forgotPasswordEmail}
|
||||
onChange={(e) => setForgotPasswordEmail(e.target.value)}
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
type='email'
|
||||
className={cn(
|
||||
resetStatus.type === 'error' && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
{resetStatus.type === 'error' && (
|
||||
<div className='mt-1 text-red-400 text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{resetStatus.type === 'error' && (
|
||||
<div className='mt-1 text-red-400 text-xs'>
|
||||
</div>
|
||||
{resetStatus.type === 'success' && (
|
||||
<div className='mt-1 text-[#4CAF50] text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isSubmittingReset}
|
||||
loading={isSubmittingReset}
|
||||
loadingText='Sending'
|
||||
>
|
||||
Send Reset Link
|
||||
</BrandedButton>
|
||||
</div>
|
||||
{resetStatus.type === 'success' && (
|
||||
<div className='mt-1 text-[#4CAF50] text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<BrandedButton
|
||||
type='button'
|
||||
onClick={handleForgotPassword}
|
||||
disabled={isSubmittingReset}
|
||||
loading={isSubmittingReset}
|
||||
loadingText='Sending'
|
||||
>
|
||||
Send Reset Link
|
||||
</BrandedButton>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useMemo, useState } from 'react'
|
||||
import { Suspense, useMemo, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -90,6 +91,9 @@ function SignupFormContent({
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const redirectUrl = useMemo(
|
||||
@@ -245,6 +249,21 @@ function SignupFormContent({
|
||||
|
||||
const sanitizedName = trimmedName
|
||||
|
||||
// Execute Turnstile challenge on submit and get a fresh token
|
||||
let token: string | undefined
|
||||
if (turnstileSiteKey && turnstileRef.current) {
|
||||
try {
|
||||
turnstileRef.current.reset()
|
||||
turnstileRef.current.execute()
|
||||
token = await turnstileRef.current.getResponsePromise(15_000)
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setFormError(null)
|
||||
const response = await client.signUp.email(
|
||||
{
|
||||
email: emailValue,
|
||||
@@ -252,6 +271,11 @@ function SignupFormContent({
|
||||
name: sanitizedName,
|
||||
},
|
||||
{
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
...(token ? { 'x-captcha-response': token } : {}),
|
||||
},
|
||||
},
|
||||
onError: (ctx) => {
|
||||
logger.error('Signup error:', ctx.error)
|
||||
const errorMessage: string[] = ['Failed to create account']
|
||||
@@ -453,6 +477,20 @@ function SignupFormContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ size: 'invisible', execution: 'execute' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<div className='text-red-400 text-xs'>
|
||||
<p>{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={isLoading}
|
||||
|
||||
+18
-20
@@ -8,6 +8,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { nextCookies } from 'better-auth/next-js'
|
||||
import {
|
||||
admin,
|
||||
captcha,
|
||||
createAuthMiddleware,
|
||||
customSession,
|
||||
emailOTP,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
oneTimeToken,
|
||||
organization,
|
||||
} from 'better-auth/plugins'
|
||||
import { emailHarmony } from 'better-auth-harmony'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { headers } from 'next/headers'
|
||||
import Stripe from 'stripe'
|
||||
@@ -63,17 +65,14 @@ import {
|
||||
isHosted,
|
||||
isOrganizationsEnabled,
|
||||
isRegistrationDisabled,
|
||||
isSignupEmailValidationEnabled,
|
||||
} from '@/lib/core/config/feature-flags'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
|
||||
import {
|
||||
isDisposableEmailFull,
|
||||
isDisposableMxBackend,
|
||||
quickValidateEmail,
|
||||
} from '@/lib/messaging/email/validation'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
|
||||
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
|
||||
@@ -629,23 +628,12 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.path.startsWith('/sign-up')) {
|
||||
if (ctx.path.startsWith('/sign-up') && blockedSignupDomains) {
|
||||
const requestEmail = ctx.body?.email?.toLowerCase()
|
||||
if (requestEmail) {
|
||||
// Check manually blocked domains
|
||||
if (blockedSignupDomains) {
|
||||
const emailDomain = requestEmail.split('@')[1]
|
||||
if (emailDomain && blockedSignupDomains.has(emailDomain)) {
|
||||
throw new Error('Sign-ups from this email domain are not allowed.')
|
||||
}
|
||||
}
|
||||
|
||||
// Check disposable email domains (full list + MX backend check)
|
||||
if (isDisposableEmailFull(requestEmail)) {
|
||||
throw new Error('Sign-ups from disposable email addresses are not allowed.')
|
||||
}
|
||||
if (await isDisposableMxBackend(requestEmail)) {
|
||||
throw new Error('Sign-ups from disposable email addresses are not allowed.')
|
||||
const emailDomain = requestEmail.split('@')[1]
|
||||
if (emailDomain && blockedSignupDomains.has(emailDomain)) {
|
||||
throw new Error('Sign-ups from this email domain are not allowed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,6 +665,16 @@ export const auth = betterAuth({
|
||||
},
|
||||
plugins: [
|
||||
nextCookies(),
|
||||
...(isSignupEmailValidationEnabled ? [emailHarmony()] : []),
|
||||
...(env.TURNSTILE_SECRET_KEY
|
||||
? [
|
||||
captcha({
|
||||
provider: 'cloudflare-turnstile',
|
||||
secretKey: env.TURNSTILE_SECRET_KEY,
|
||||
endpoints: ['/sign-up/email', '/sign-in/email'],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
admin(),
|
||||
jwt({
|
||||
jwks: {
|
||||
|
||||
@@ -25,6 +25,8 @@ export const env = createEnv({
|
||||
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
|
||||
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
|
||||
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")
|
||||
TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification
|
||||
SIGNUP_EMAIL_VALIDATION_ENABLED: z.boolean().optional(), // Enable disposable email blocking via better-auth-harmony (55K+ domains)
|
||||
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
|
||||
API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS)
|
||||
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication
|
||||
@@ -411,6 +413,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally
|
||||
NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted
|
||||
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget
|
||||
},
|
||||
|
||||
// Variables available on both server and client
|
||||
@@ -444,6 +447,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API,
|
||||
NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED,
|
||||
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
|
||||
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,
|
||||
NEXT_PUBLIC_ENABLE_PLAYGROUND: process.env.NEXT_PUBLIC_ENABLE_PLAYGROUND,
|
||||
|
||||
@@ -70,6 +70,11 @@ export const isRegistrationDisabled = isTruthy(env.DISABLE_REGISTRATION)
|
||||
*/
|
||||
export const isEmailPasswordEnabled = !isFalsy(env.EMAIL_PASSWORD_SIGNUP_ENABLED)
|
||||
|
||||
/**
|
||||
* Is signup email validation enabled (disposable email blocking via better-auth-harmony)
|
||||
*/
|
||||
export const isSignupEmailValidationEnabled = isTruthy(env.SIGNUP_EMAIL_VALIDATION_ENABLED)
|
||||
|
||||
/**
|
||||
* Is Trigger.dev enabled for async job processing
|
||||
*/
|
||||
|
||||
@@ -1,354 +1,147 @@
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
isDisposableEmailFull,
|
||||
isDisposableMxBackend,
|
||||
quickValidateEmail,
|
||||
validateEmail,
|
||||
} from '@/lib/messaging/email/validation'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
const { mockResolveMx } = vi.hoisted(() => ({
|
||||
mockResolveMx: vi.fn(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'mail.example.com', priority: 10 }])
|
||||
}
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('dns', () => ({
|
||||
resolveMx: mockResolveMx,
|
||||
}))
|
||||
|
||||
describe('Email Validation', () => {
|
||||
beforeEach(() => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'mail.example.com', priority: 10 }])
|
||||
}
|
||||
)
|
||||
describe('quickValidateEmail', () => {
|
||||
it.concurrent('should validate a correct email', () => {
|
||||
const result = quickValidateEmail('user@example.com')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.checks.syntax).toBe(true)
|
||||
expect(result.checks.disposable).toBe(true)
|
||||
expect(result.checks.mxRecord).toBe(true)
|
||||
expect(result.confidence).toBe('medium')
|
||||
})
|
||||
|
||||
describe('validateEmail', () => {
|
||||
it.concurrent('should validate a correct email', async () => {
|
||||
const result = await validateEmail('user@example.com')
|
||||
it.concurrent('should reject invalid syntax', () => {
|
||||
const result = quickValidateEmail('invalid-email')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject disposable email addresses', () => {
|
||||
const disposableDomains = [
|
||||
'mailinator.com',
|
||||
'yopmail.com',
|
||||
'guerrillamail.com',
|
||||
'temp-mail.org',
|
||||
'throwaway.email',
|
||||
'getnada.com',
|
||||
'sharklasers.com',
|
||||
'spam4.me',
|
||||
'sharebot.net',
|
||||
'oakon.com',
|
||||
'catchmail.io',
|
||||
'salt.email',
|
||||
'mail.gw',
|
||||
'tempmail.org',
|
||||
]
|
||||
|
||||
for (const domain of disposableDomains) {
|
||||
const result = quickValidateEmail(`test@${domain}`)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Disposable email addresses are not allowed')
|
||||
expect(result.checks.disposable).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should reject consecutive dots (RFC violation)', () => {
|
||||
const result = quickValidateEmail('user..name@example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Email contains suspicious patterns')
|
||||
expect(result.confidence).toBe('medium')
|
||||
})
|
||||
|
||||
it.concurrent('should reject very long local parts (RFC violation)', () => {
|
||||
const longLocalPart = 'a'.repeat(65)
|
||||
const result = quickValidateEmail(`${longLocalPart}@example.com`)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Email contains suspicious patterns')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with missing domain', () => {
|
||||
const result = quickValidateEmail('user@')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with domain starting with dot', () => {
|
||||
const result = quickValidateEmail('user@.example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with domain ending with dot', () => {
|
||||
const result = quickValidateEmail('user@example.')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with domain missing TLD', () => {
|
||||
const result = quickValidateEmail('user@localhost')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid domain format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email longer than 254 characters', () => {
|
||||
const longLocal = 'a'.repeat(64)
|
||||
const longDomain = `${'b'.repeat(180)}.com`
|
||||
const result = quickValidateEmail(`${longLocal}@${longDomain}`)
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should accept valid email formats', () => {
|
||||
const validEmails = [
|
||||
'simple@example.com',
|
||||
'very.common@example.com',
|
||||
'disposable.style.email.with+symbol@example.com',
|
||||
'other.email-with-hyphen@example.com',
|
||||
'user.name+tag+sorting@example.com',
|
||||
'x@example.com',
|
||||
'example-indeed@strange-example.com',
|
||||
'example@s.example',
|
||||
]
|
||||
|
||||
for (const email of validEmails) {
|
||||
const result = quickValidateEmail(email)
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.checks.syntax).toBe(true)
|
||||
expect(result.checks.disposable).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should reject invalid syntax', async () => {
|
||||
const result = await validateEmail('invalid-email')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
expect(result.checks.syntax).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject disposable email addresses', async () => {
|
||||
const result = await validateEmail('test@10minutemail.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Disposable email addresses are not allowed')
|
||||
expect(result.checks.disposable).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should reject consecutive dots (RFC violation)', async () => {
|
||||
const result = await validateEmail('user..name@example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Email contains suspicious patterns')
|
||||
})
|
||||
|
||||
it.concurrent('should reject very long local parts (RFC violation)', async () => {
|
||||
const longLocalPart = 'a'.repeat(65)
|
||||
const result = await validateEmail(`${longLocalPart}@example.com`)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Email contains suspicious patterns')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with missing domain', async () => {
|
||||
const result = await validateEmail('user@')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with domain starting with dot', async () => {
|
||||
const result = await validateEmail('user@.example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
// The regex catches this as a syntax error before domain validation
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with domain ending with dot', async () => {
|
||||
const result = await validateEmail('user@example.')
|
||||
expect(result.isValid).toBe(false)
|
||||
// The regex catches this as a syntax error before domain validation
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with domain missing TLD', async () => {
|
||||
const result = await validateEmail('user@localhost')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid domain format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email longer than 254 characters', async () => {
|
||||
const longLocal = 'a'.repeat(64)
|
||||
const longDomain = `${'b'.repeat(180)}.com`
|
||||
const result = await validateEmail(`${longLocal}@${longDomain}`)
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should validate various known disposable email domains', async () => {
|
||||
const disposableDomains = [
|
||||
'mailinator.com',
|
||||
'yopmail.com',
|
||||
'guerrillamail.com',
|
||||
'temp-mail.org',
|
||||
'throwaway.email',
|
||||
'getnada.com',
|
||||
'sharklasers.com',
|
||||
'spam4.me',
|
||||
]
|
||||
|
||||
for (const domain of disposableDomains) {
|
||||
const result = await validateEmail(`test@${domain}`)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Disposable email addresses are not allowed')
|
||||
expect(result.checks.disposable).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should accept valid email formats', async () => {
|
||||
const validEmails = [
|
||||
'simple@example.com',
|
||||
'very.common@example.com',
|
||||
'disposable.style.email.with+symbol@example.com',
|
||||
'other.email-with-hyphen@example.com',
|
||||
'fully-qualified-domain@example.com',
|
||||
'user.name+tag+sorting@example.com',
|
||||
'x@example.com',
|
||||
'example-indeed@strange-example.com',
|
||||
'example@s.example',
|
||||
]
|
||||
|
||||
for (const email of validEmails) {
|
||||
const result = await validateEmail(email)
|
||||
// We check syntax passes; MX might fail for fake domains
|
||||
expect(result.checks.syntax).toBe(true)
|
||||
expect(result.checks.disposable).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should return high confidence for syntax failures', async () => {
|
||||
const result = await validateEmail('not-an-email')
|
||||
expect(result.confidence).toBe('high')
|
||||
})
|
||||
|
||||
it.concurrent('should handle email with special characters in local part', async () => {
|
||||
const result = await validateEmail("user!#$%&'*+/=?^_`{|}~@example.com")
|
||||
expect(result.checks.syntax).toBe(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('quickValidateEmail', () => {
|
||||
it.concurrent('should validate quickly without MX check', () => {
|
||||
const result = quickValidateEmail('user@example.com')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.checks.mxRecord).toBe(true) // Skipped, so assumed true
|
||||
expect(result.confidence).toBe('medium')
|
||||
})
|
||||
|
||||
it.concurrent('should reject invalid emails quickly', () => {
|
||||
const result = quickValidateEmail('invalid-email')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject disposable emails quickly', () => {
|
||||
const result = quickValidateEmail('test@tempmail.org')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Disposable email addresses are not allowed')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with missing domain', () => {
|
||||
const result = quickValidateEmail('user@')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should reject email with invalid domain format', () => {
|
||||
const result = quickValidateEmail('user@.invalid')
|
||||
expect(result.isValid).toBe(false)
|
||||
// The regex catches this as a syntax error before domain validation
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should return medium confidence for suspicious patterns', () => {
|
||||
const result = quickValidateEmail('user..double@example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Email contains suspicious patterns')
|
||||
expect(result.confidence).toBe('medium')
|
||||
})
|
||||
|
||||
it.concurrent('should return high confidence for syntax errors', () => {
|
||||
const result = quickValidateEmail('not-valid-email')
|
||||
expect(result.confidence).toBe('high')
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty string', () => {
|
||||
const result = quickValidateEmail('')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should handle email with only @ symbol', () => {
|
||||
const result = quickValidateEmail('@')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should handle email with spaces', () => {
|
||||
const result = quickValidateEmail('user name@example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should handle email with multiple @ symbols', () => {
|
||||
const result = quickValidateEmail('user@domain@example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
it.concurrent('should validate complex but valid local parts', () => {
|
||||
const result = quickValidateEmail('user+tag@example.com')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.checks.syntax).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should validate subdomains', () => {
|
||||
const result = quickValidateEmail('user@mail.subdomain.example.com')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.checks.domain).toBe(true)
|
||||
})
|
||||
it.concurrent('should return high confidence for syntax errors', () => {
|
||||
const result = quickValidateEmail('not-valid-email')
|
||||
expect(result.confidence).toBe('high')
|
||||
})
|
||||
|
||||
describe('isDisposableEmailFull', () => {
|
||||
it('should reject domains from the inline blocklist', () => {
|
||||
expect(isDisposableEmailFull('user@sharebot.net')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@oakon.com')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@catchmail.io')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@salt.email')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@mail.gw')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@mailinator.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject domains from the npm package list that are not in the inline list', () => {
|
||||
expect(isDisposableEmailFull('user@0-mail.com')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@0-180.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow legitimate email domains', () => {
|
||||
expect(isDisposableEmailFull('user@gmail.com')).toBe(false)
|
||||
expect(isDisposableEmailFull('user@company.com')).toBe(false)
|
||||
expect(isDisposableEmailFull('user@outlook.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle invalid input', () => {
|
||||
expect(isDisposableEmailFull('')).toBe(false)
|
||||
expect(isDisposableEmailFull('nodomain')).toBe(false)
|
||||
expect(isDisposableEmailFull('user@')).toBe(false)
|
||||
})
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
expect(isDisposableEmailFull('user@MAILINATOR.COM')).toBe(true)
|
||||
expect(isDisposableEmailFull('user@ShareBot.Net')).toBe(true)
|
||||
})
|
||||
it.concurrent('should handle special characters in local part', () => {
|
||||
const result = quickValidateEmail("user!#$%&'*+/=?^_`{|}~@example.com")
|
||||
expect(result.checks.syntax).toBe(true)
|
||||
})
|
||||
|
||||
describe('isDisposableMxBackend', () => {
|
||||
it('should detect mail.gw MX backend', async () => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'in.mail.gw', priority: 10 }])
|
||||
}
|
||||
)
|
||||
expect(await isDisposableMxBackend('user@some-random-domain.xyz')).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect catchmail.io MX backend', async () => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'smtp.catchmail.io', priority: 10 }])
|
||||
}
|
||||
)
|
||||
expect(await isDisposableMxBackend('user@custom-domain.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle trailing dot in MX exchange', async () => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'in.mail.gw.', priority: 10 }])
|
||||
}
|
||||
)
|
||||
expect(await isDisposableMxBackend('user@trailing-dot.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow legitimate MX backends', async () => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'aspmx.l.google.com', priority: 10 }])
|
||||
}
|
||||
)
|
||||
expect(await isDisposableMxBackend('user@legitimate.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false on DNS errors', async () => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(_domain: string, callback: (err: Error | null, addresses: null) => void) => {
|
||||
callback(new Error('ENOTFOUND'), null)
|
||||
}
|
||||
)
|
||||
expect(await isDisposableMxBackend('user@nonexistent.invalid')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for invalid input', async () => {
|
||||
expect(await isDisposableMxBackend('')).toBe(false)
|
||||
expect(await isDisposableMxBackend('nodomain')).toBe(false)
|
||||
})
|
||||
it.concurrent('should handle empty string', () => {
|
||||
const result = quickValidateEmail('')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Invalid email format')
|
||||
})
|
||||
|
||||
describe('validateEmail with disposable MX backend', () => {
|
||||
it('should reject emails with disposable MX backend even if domain is not in blocklist', async () => {
|
||||
mockResolveMx.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void
|
||||
) => {
|
||||
callback(null, [{ exchange: 'in.mail.gw', priority: 10 }])
|
||||
}
|
||||
)
|
||||
const result = await validateEmail('user@unknown-disposable.xyz')
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.reason).toBe('Disposable email addresses are not allowed')
|
||||
expect(result.checks.disposable).toBe(false)
|
||||
})
|
||||
it.concurrent('should handle email with only @ symbol', () => {
|
||||
const result = quickValidateEmail('@')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should handle email with spaces', () => {
|
||||
const result = quickValidateEmail('user name@example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should handle email with multiple @ symbols', () => {
|
||||
const result = quickValidateEmail('user@domain@example.com')
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should validate subdomains', () => {
|
||||
const result = quickValidateEmail('user@mail.subdomain.example.com')
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.checks.domain).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('EmailValidation')
|
||||
|
||||
export interface EmailValidationResult {
|
||||
isValid: boolean
|
||||
reason?: string
|
||||
@@ -14,8 +10,8 @@ export interface EmailValidationResult {
|
||||
}
|
||||
}
|
||||
|
||||
/** Common disposable domains for fast client-side checks (no heavy import needed) */
|
||||
const DISPOSABLE_DOMAINS_INLINE = new Set([
|
||||
/** Common disposable domains for fast client-side feedback */
|
||||
const DISPOSABLE_DOMAINS = new Set([
|
||||
'10minutemail.com',
|
||||
'10minutemail.net',
|
||||
'catchmail.io',
|
||||
@@ -42,37 +38,6 @@ const DISPOSABLE_DOMAINS_INLINE = new Set([
|
||||
'yopmail.com',
|
||||
])
|
||||
|
||||
/** Full disposable domain list from npm package (~5.3K domains), lazy-loaded server-side only */
|
||||
let disposableDomainsFull: Set<string> | null = null
|
||||
|
||||
function getDisposableDomainsFull(): Set<string> {
|
||||
if (!disposableDomainsFull) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const domains = require('disposable-email-domains') as string[]
|
||||
disposableDomainsFull = new Set(domains)
|
||||
} catch {
|
||||
logger.warn('Failed to load disposable-email-domains package')
|
||||
disposableDomainsFull = new Set()
|
||||
}
|
||||
}
|
||||
return disposableDomainsFull
|
||||
}
|
||||
|
||||
/** MX hostnames used by known disposable email backends */
|
||||
const DISPOSABLE_MX_BACKENDS = new Set(['in.mail.gw', 'smtp.catchmail.io', 'mx.yopmail.com'])
|
||||
|
||||
/** Per-domain MX result cache — avoids redundant DNS queries for concurrent or repeated sign-ups */
|
||||
const mxCache = new Map<string, { result: boolean; expires: number }>()
|
||||
const MX_CACHE_MAX = 1_000
|
||||
|
||||
function setMxCache(domain: string, entry: { result: boolean; expires: number }) {
|
||||
if (mxCache.size >= MX_CACHE_MAX && !mxCache.has(domain)) {
|
||||
mxCache.delete(mxCache.keys().next().value!)
|
||||
}
|
||||
mxCache.set(domain, entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates email syntax using RFC 5322 compliant regex
|
||||
*/
|
||||
@@ -83,87 +48,19 @@ function validateEmailSyntax(email: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if domain has valid MX records and is not backed by a disposable email service (server-side only)
|
||||
* Checks if email is from a known disposable email provider
|
||||
*/
|
||||
async function checkMXRecord(
|
||||
domain: string
|
||||
): Promise<{ exists: boolean; isDisposableBackend: boolean }> {
|
||||
// Skip MX check on client-side (browser)
|
||||
if (typeof window !== 'undefined') {
|
||||
return { exists: true, isDisposableBackend: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const { promisify } = await import('util')
|
||||
const dns = await import('dns')
|
||||
const resolveMx = promisify(dns.resolveMx)
|
||||
|
||||
const mxRecords = await resolveMx(domain)
|
||||
if (!mxRecords || mxRecords.length === 0) {
|
||||
return { exists: false, isDisposableBackend: false }
|
||||
}
|
||||
|
||||
const isDisposableBackend = mxRecords.some((record: { exchange: string }) =>
|
||||
DISPOSABLE_MX_BACKENDS.has(record.exchange.toLowerCase().replace(/\.$/, ''))
|
||||
)
|
||||
|
||||
return { exists: true, isDisposableBackend }
|
||||
} catch (error) {
|
||||
logger.debug('MX record check failed', { domain, error })
|
||||
return { exists: false, isDisposableBackend: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks against the full disposable email domain list (~5.3K domains server-side, inline list client-side)
|
||||
*/
|
||||
export function isDisposableEmailFull(email: string): boolean {
|
||||
function isDisposableEmail(email: string): boolean {
|
||||
const domain = email.split('@')[1]?.toLowerCase()
|
||||
if (!domain) return false
|
||||
return DISPOSABLE_DOMAINS_INLINE.has(domain) || getDisposableDomainsFull().has(domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an email's MX records point to a known disposable email backend (server-side only)
|
||||
*/
|
||||
export async function isDisposableMxBackend(email: string): Promise<boolean> {
|
||||
const domain = email.split('@')[1]?.toLowerCase()
|
||||
if (!domain) return false
|
||||
|
||||
const now = Date.now()
|
||||
const cached = mxCache.get(domain)
|
||||
if (cached) {
|
||||
if (cached.expires > now) return cached.result
|
||||
mxCache.delete(domain)
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const mxCheckPromise = checkMXRecord(domain)
|
||||
const timeoutPromise = new Promise<{ exists: boolean; isDisposableBackend: boolean }>(
|
||||
(_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error('MX check timeout')), 5000)
|
||||
}
|
||||
)
|
||||
const result = await Promise.race([mxCheckPromise, timeoutPromise])
|
||||
setMxCache(domain, { result: result.isDisposableBackend, expires: now + 5 * 60 * 1000 })
|
||||
return result.isDisposableBackend
|
||||
} catch {
|
||||
setMxCache(domain, { result: false, expires: now + 60 * 1000 })
|
||||
return false
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
return domain ? DISPOSABLE_DOMAINS.has(domain) : false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for obvious patterns that indicate invalid emails
|
||||
*/
|
||||
function hasInvalidPatterns(email: string): boolean {
|
||||
// Check for consecutive dots (RFC violation)
|
||||
if (email.includes('..')) return true
|
||||
|
||||
// Check for local part length (RFC limit is 64 characters)
|
||||
const localPart = email.split('@')[0]
|
||||
if (localPart && localPart.length > 64) return true
|
||||
|
||||
@@ -171,133 +68,14 @@ function hasInvalidPatterns(email: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an email address comprehensively
|
||||
*/
|
||||
export async function validateEmail(email: string): Promise<EmailValidationResult> {
|
||||
const checks = {
|
||||
syntax: false,
|
||||
domain: false,
|
||||
mxRecord: false,
|
||||
disposable: false,
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Basic syntax validation
|
||||
checks.syntax = validateEmailSyntax(email)
|
||||
if (!checks.syntax) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Invalid email format',
|
||||
confidence: 'high',
|
||||
checks,
|
||||
}
|
||||
}
|
||||
|
||||
const domain = email.split('@')[1]?.toLowerCase()
|
||||
if (!domain) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Missing domain',
|
||||
confidence: 'high',
|
||||
checks,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for disposable email against full list (server-side)
|
||||
checks.disposable = !isDisposableEmailFull(email)
|
||||
if (!checks.disposable) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Disposable email addresses are not allowed',
|
||||
confidence: 'high',
|
||||
checks,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for invalid patterns
|
||||
if (hasInvalidPatterns(email)) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Email contains suspicious patterns',
|
||||
confidence: 'high',
|
||||
checks,
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Domain validation - check for obvious invalid domains
|
||||
checks.domain = domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.')
|
||||
if (!checks.domain) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Invalid domain format',
|
||||
confidence: 'high',
|
||||
checks,
|
||||
}
|
||||
}
|
||||
|
||||
// 5. MX record check (with timeout) — also detects disposable email backends
|
||||
let mxTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const mxCheckPromise = checkMXRecord(domain)
|
||||
const timeoutPromise = new Promise<{ exists: boolean; isDisposableBackend: boolean }>(
|
||||
(_, reject) => {
|
||||
mxTimeoutId = setTimeout(() => reject(new Error('MX check timeout')), 5000)
|
||||
}
|
||||
)
|
||||
|
||||
const mxResult = await Promise.race([mxCheckPromise, timeoutPromise])
|
||||
checks.mxRecord = mxResult.exists
|
||||
|
||||
if (mxResult.isDisposableBackend) {
|
||||
checks.disposable = false
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Disposable email addresses are not allowed',
|
||||
confidence: 'high',
|
||||
checks,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('MX record check failed or timed out', { domain, error })
|
||||
checks.mxRecord = false
|
||||
} finally {
|
||||
clearTimeout(mxTimeoutId)
|
||||
}
|
||||
|
||||
// Determine overall validity and confidence
|
||||
if (!checks.mxRecord) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Domain does not accept emails (no MX records)',
|
||||
confidence: 'high',
|
||||
checks,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
confidence: 'high',
|
||||
checks,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Email validation error', { email, error })
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Validation service temporarily unavailable',
|
||||
confidence: 'low',
|
||||
checks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick validation for high-volume scenarios (skips MX check)
|
||||
* Quick email validation for client-side form feedback.
|
||||
* Server-side disposable blocking is handled by better-auth-harmony (55K+ domains).
|
||||
*/
|
||||
export function quickValidateEmail(email: string): EmailValidationResult {
|
||||
const checks = {
|
||||
syntax: false,
|
||||
domain: false,
|
||||
mxRecord: true, // Skip MX check for performance
|
||||
mxRecord: true,
|
||||
disposable: false,
|
||||
}
|
||||
|
||||
@@ -321,7 +99,7 @@ export function quickValidateEmail(email: string): EmailValidationResult {
|
||||
}
|
||||
}
|
||||
|
||||
checks.disposable = !isDisposableEmailFull(email)
|
||||
checks.disposable = !isDisposableEmail(email)
|
||||
if (!checks.disposable) {
|
||||
return {
|
||||
isValid: false,
|
||||
|
||||
@@ -120,6 +120,7 @@ const nextConfig: NextConfig = {
|
||||
'@t3-oss/env-nextjs',
|
||||
'@t3-oss/env-core',
|
||||
'@sim/db',
|
||||
'better-auth-harmony',
|
||||
],
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@azure/storage-blob": "12.27.0",
|
||||
"@better-auth/sso": "1.3.12",
|
||||
"@better-auth/stripe": "1.3.12",
|
||||
"@marsidev/react-turnstile": "1.4.2",
|
||||
"@browserbasehq/stagehand": "^3.0.5",
|
||||
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
|
||||
"@e2b/code-interpreter": "^2.0.0",
|
||||
@@ -87,6 +88,7 @@
|
||||
"@types/react-window": "2.0.0",
|
||||
"@types/three": "0.177.0",
|
||||
"better-auth": "1.3.12",
|
||||
"better-auth-harmony": "1.3.1",
|
||||
"binary-extensions": "^2.0.0",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"chalk": "5.6.2",
|
||||
@@ -99,7 +101,6 @@
|
||||
"csv-parse": "6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"decimal.js": "10.6.0",
|
||||
"disposable-email-domains": "1.0.62",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"encoding": "0.1.13",
|
||||
"entities": "6.0.1",
|
||||
|
||||
Reference in New Issue
Block a user