mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04: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:
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
15
bun.lock
15
bun.lock
@@ -73,6 +73,7 @@
|
||||
"@google/genai": "1.34.0",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@linear/sdk": "40.0.0",
|
||||
"@marsidev/react-turnstile": "1.4.2",
|
||||
"@modelcontextprotocol/sdk": "1.20.2",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-jaeger": "2.1.0",
|
||||
@@ -113,6 +114,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",
|
||||
@@ -125,7 +127,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",
|
||||
@@ -810,6 +811,8 @@
|
||||
|
||||
"@linear/sdk": ["@linear/sdk@40.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.0", "graphql": "^15.4.0", "isomorphic-unfetch": "^3.1.0" } }, "sha512-R4lyDIivdi00fO+DYPs7gWNX221dkPJhgDowFrsfos/rNG6o5HixsCPgwXWtKN0GA0nlqLvFTmzvzLXpud1xKw=="],
|
||||
|
||||
"@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.4.2", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-xs1qOuyeMOz6t9BXXCXWiukC0/0+48vR08B7uwNdG05wCMnbcNgxiFmdFKDOFbM76qFYFRYlGeRfhfq1U/iZmA=="],
|
||||
|
||||
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
|
||||
|
||||
"@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="],
|
||||
@@ -1762,6 +1765,8 @@
|
||||
|
||||
"better-auth": ["better-auth@1.3.12", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.19", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.5" }, "peerDependencies": { "@lynx-js/react": "*", "@sveltejs/kit": "^2.0.0", "next": "^14.0.0 || ^15.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@sveltejs/kit", "next", "react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-FckxiAexCkkk2F0EOPmhXjWhYYE8eYg2x68lOIirSgyQ0TWc4JDvA5y8Vax5Jc7iyXk5MjJBY3DfwTPDZ87Lbg=="],
|
||||
|
||||
"better-auth-harmony": ["better-auth-harmony@1.3.1", "", { "dependencies": { "libphonenumber-js": "^1.12.37", "mailchecker": "^6.0.19", "validator": "^13.15.26" }, "peerDependencies": { "better-auth": "^1.0.3" } }, "sha512-CiChZMBxuq35YqwyA2pcuL7KfAdrxa+VGLShL+yortprC5E04kttV0XsdsaTIej+d0MxFKIcq7PPaInaEyV3DQ=="],
|
||||
|
||||
"better-call": ["better-call@1.0.19", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw=="],
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
@@ -2056,8 +2061,6 @@
|
||||
|
||||
"dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="],
|
||||
|
||||
"disposable-email-domains": ["disposable-email-domains@1.0.62", "", {}, "sha512-LBQvhRw7mznQTPoyZbsmYeNOZt1pN5aCsx4BAU/3siVFuiM9f2oyKzUaB8v1jbxFjE3aYqYiMo63kAL4pHgfWQ=="],
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"dockerfile-ast": ["dockerfile-ast@0.7.1", "", { "dependencies": { "vscode-languageserver-textdocument": "^1.0.8", "vscode-languageserver-types": "^3.17.3" } }, "sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw=="],
|
||||
@@ -2576,6 +2579,8 @@
|
||||
|
||||
"libmime": ["libmime@5.3.7", "", { "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw=="],
|
||||
|
||||
"libphonenumber-js": ["libphonenumber-js@1.12.40", "", {}, "sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg=="],
|
||||
|
||||
"libqp": ["libqp@2.1.1", "", {}, "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="],
|
||||
|
||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
@@ -2660,6 +2665,8 @@
|
||||
|
||||
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
|
||||
|
||||
"mailchecker": ["mailchecker@6.0.20", "", {}, "sha512-mZ3kmtfXzGj06prtNm6d8an7D++Kf1G4jEkPZ1QQyhknYNLkmGoMtfaNPNHJU6E8J+Bm3AcZlIIfq5D6L4MS2g=="],
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
"mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="],
|
||||
@@ -3622,6 +3629,8 @@
|
||||
|
||||
"uzip": ["uzip@0.20201231.0", "", {}, "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng=="],
|
||||
|
||||
"validator": ["validator@13.15.26", "", {}, "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
@@ -176,6 +176,11 @@ app:
|
||||
DISABLE_REGISTRATION: "" # Set to "true" to disable new user signups
|
||||
EMAIL_PASSWORD_SIGNUP_ENABLED: "" # Set to "false" to disable email/password login (SSO-only mode, server-side enforcement)
|
||||
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: "" # Set to "false" to hide email/password login form (UI-side)
|
||||
SIGNUP_EMAIL_VALIDATION_ENABLED: "" # Set to "true" to block 55K+ disposable email domains (requires normalized_email migration)
|
||||
|
||||
# Bot Protection (Cloudflare Turnstile)
|
||||
TURNSTILE_SECRET_KEY: "" # Cloudflare Turnstile secret key (leave empty to disable captcha)
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: "" # Cloudflare Turnstile site key (leave empty to disable captcha)
|
||||
|
||||
# Access Control (leave empty if not restricting login)
|
||||
ALLOWED_LOGIN_EMAILS: "" # Comma-separated list of allowed email addresses for login
|
||||
|
||||
2
packages/db/migrations/0178_clumsy_living_mummy.sql
Normal file
2
packages/db/migrations/0178_clumsy_living_mummy.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user" ADD COLUMN "normalized_email" text;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD CONSTRAINT "user_normalized_email_unique" UNIQUE("normalized_email");
|
||||
13577
packages/db/migrations/meta/0178_snapshot.json
Normal file
13577
packages/db/migrations/meta/0178_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1240,6 +1240,13 @@
|
||||
"when": 1773698715278,
|
||||
"tag": "0177_wise_puma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 178,
|
||||
"version": "7",
|
||||
"when": 1774112405784,
|
||||
"tag": "0178_clumsy_living_mummy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const user = pgTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
normalizedEmail: text('normalized_email').unique(),
|
||||
emailVerified: boolean('email_verified').notNull(),
|
||||
image: text('image'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
|
||||
Reference in New Issue
Block a user