Email Validation System
Comprehensive guide to the universal email validation system with real-time feedback and customizable validation logic
The Email Validation System provides intelligent, user-friendly email validation with real-time feedback and customizable validation logic for any email input across the application.
Overview
This universal system validates email addresses with configurable validation functions, debouncing, and visual feedback. It follows the same architectural pattern as the Slug Validation System for consistency.
Key Features
- Universal Pattern: Single reusable hook for all email inputs
- Basic Format Validation: Built-in regex validation for email format
- Custom Validation Logic: Flexible validation function for specific use cases
- Real-time Feedback: Debounced validation with visual indicators
- Context Support: Pass additional data to validation functions
- Smart Cleanup: Abort controllers prevent memory leaks
- Type Safety: Full TypeScript support with clear interfaces
Architecture
Client-Side Components
1. useEmailValidation Hook
Location: packages/shared/src/hooks/use-email-validation.ts
interface UseEmailValidationOptions {
currentEmail?: string | null;
debounceMs?: number;
validateFn: (
email: string,
context?: Record<string, string>,
) => Promise<{ valid: boolean; message?: string; }>;
context?: Record<string, string>;
}
interface UseEmailValidationReturn {
isValidating: boolean;
isValid: boolean | null;
error: string | null;
validate: (email: string) => Promise<void>;
}
```
**Features**:
- Debounced validation (default 500ms, customizable)
- Abort previous requests on new validation
- Built-in email format validation (regex)
- Skip validation for unchanged emails
- Configurable validation function with context
- Error message handling
- TypeScript support
**Usage Example**:
```typescript
const emailValidation = useEmailValidation({
currentEmail: user.email, // Optional: skip if unchanged
debounceMs: 500,
validateFn: async (email, context) => {
const response = await fetch(
`/api/validate-email?email=${email}&token=${context?.inviteToken}`
);
const data = await response.json();
return {
valid: data.matches,
message: data.matches
? undefined
: 'Email does not match invitation'
};
},
context: { inviteToken: 'abc123' }
});
// In useEffect:
useEffect(() => {
if (email) {
emailValidation.validate(email);
}
}, [email]);
```
#### 2. Email Format Validation
**Built-in Regex**:
```typescript
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
```
**Validation Rules**:
- Must contain `@` symbol
- Must have local part before `@`
- Must have domain part after `@`
- Domain must contain at least one `.`
- No whitespace allowed
**Valid Examples**:
```
user@example.com
john.doe@company.co.uk
test+tag@domain.org
admin@subdomain.example.com
```
**Invalid Examples**:
```
plainaddress (no @ symbol)
@missinglocal.com (no local part)
user@ (no domain)
user @space.com (whitespace)
user@nodot (no . in domain)
```
### Visual Feedback Pattern
#### InputGroup Implementation
Following the same pattern as slug validation for consistency:
```tsx
<InputGroup>
<InputGroupAddon align="inline-start">
<InputGroupText>
<Mail className="h-4 w-4" />
</InputGroupText>
</InputGroupAddon>
<InputGroupInput type="email" className={ emailValidation.isValid===false ? 'border-destructive' :
emailValidation.isValid===true ? 'border-green-500' : '' } {...field} />
<InputGroupAddon align="inline-end">
{emailValidation.isValidating && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
{!emailValidation.isValidating && emailValidation.isValid === true && (
<CheckCircle2 className="h-4 w-4 text-green-500" />
)}
{!emailValidation.isValidating && emailValidation.isValid === false && (
<AlertCircle className="text-destructive h-4 w-4" />
)}
</InputGroupAddon>
</InputGroup>
```
**Visual States**:
- **Idle**: Default border, Mail icon on left
- **Validating**: Spinner icon on right
- **Valid**: Green border + CheckCircle icon
- **Invalid**: Red border + AlertCircle icon
### Integration with React Hook Form
```tsx
export function EmailInputComponent() {
const form = useFormContext<FormData>();
const email = form.watch('email');
const emailValidation = useEmailValidation({
validateFn: yourValidationLogic,
context: yourContext
});
// Validate on email change
useEffect(() => {
if (email) {
emailValidation.validate(email);
}
}, [email]);
// Update form errors
useEffect(() => {
if (emailValidation.error) {
form.setError('email', {
type: 'manual',
message: emailValidation.error,
});
} else if (emailValidation.isValid === true) {
form.clearErrors('email');
}
}, [emailValidation.isValid, emailValidation.error]);
return (
<FormField name="email" render={({ field })=> (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
{/* InputGroup implementation */}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
```
## Use Cases
### 1. Locked Email for Invitation Acceptance (Recommended)
**Use Case:** When users accept team or project invitations, email must be locked to prevent hijacking.
**Implementation:** No validation hook needed - email is pre-filled and completely locked.
```tsx
const inviteToken = form.watch('inviteToken');
const isEmailLocked = !!inviteToken;
<InputGroupInput {...field} type="email" readOnly={isEmailLocked} aria-readonly={isEmailLocked}
tabIndex={isEmailLocked ? -1 : 0} onKeyDown={isEmailLocked ? (e)=> e.preventDefault() : undefined}
onPaste={isEmailLocked ? (e) => e.preventDefault() : undefined}
className={
isEmailLocked
? 'bg-muted cursor-not-allowed opacity-60 select-none'
: ''
}
/>
```
**7 Protection Layers:**
- HTML5 `readOnly`, keyboard blocking, paste blocking, tab exclusion
- Visual indicators: cursor, opacity, select-none
**See:** [LOCKED_EMAIL_PROTECTION_LAYERS.md](../../../../LOCKED_EMAIL_PROTECTION_LAYERS.md)
---
### 2. Dynamic Email Validation (Alternative Pattern)
**Use Case:** Validate email against invitation when you need user to confirm (less secure).
**Note:** Not recommended for invitation acceptance. Use locked email (Use Case #1) instead.
```typescript
const emailValidation = useEmailValidation({
validateFn: async (email, context) => {
if (!context?.inviteToken) {
return { valid: false, message: 'Invalid invitation' };
}
const response = await fetch(
`/api/onboarding/validate-email?email=${encodeURIComponent(email)}&inviteToken=${encodeURIComponent(context.inviteToken)}`
);
const data = await response.json();
return {
valid: data.matches,
message: data.matches
? undefined
: 'This email does not match the invitation. Please use the email address the invitation was sent to.'
};
},
context: { inviteToken }
});
```
**Server Implementation**:
```typescript
// apps/web/app/api/onboarding/validate-email/route.ts
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const email = searchParams.get('email');
const inviteToken = searchParams.get('inviteToken');
if (!email || !inviteToken) {
return NextResponse.json({ matches: false }, { status: 400 });
}
const client = getSupabaseServerClient();
// Check account invitations
const { data: accountInvitation } = await client
.from('account_invitations')
.select('email')
.eq('invite_token', inviteToken)
.maybeSingle();
if (accountInvitation) {
return NextResponse.json({
matches: accountInvitation.email.toLowerCase() === email.toLowerCase()
});
}
// Check project invitations
const { data: projectInvitation } = await client
.from('project_invitations')
.select('email')
.eq('invite_token', inviteToken)
.maybeSingle();
return NextResponse.json({
matches: projectInvitation
? projectInvitation.email.toLowerCase() === email.toLowerCase()
: false
});
}
```
### 3. Unique Email Validation
Check if an email is already registered.
```typescript
const emailValidation = useEmailValidation({
currentEmail: user.email, // Skip if unchanged
validateFn: async (email) => {
const response = await fetch(
`/api/auth/check-email-available?email=${encodeURIComponent(email)}`
);
const data = await response.json();
return {
valid: data.available,
message: data.available
? undefined
: 'This email is already registered. Please use a different email or sign in.'
};
}
});
```
**Server Implementation**:
```typescript
export async function GET(request: NextRequest) {
const email = request.nextUrl.searchParams.get('email');
if (!email) {
return NextResponse.json({ available: false }, { status: 400 });
}
const client = getSupabaseServerClient();
// Check if email exists in auth.users
const { data: users } = await client.auth.admin.listUsers();
const emailExists = users?.users.some(
u => u.email?.toLowerCase() === email.toLowerCase()
);
return NextResponse.json({ available: !emailExists });
}
```
### 4. Domain Whitelist Validation
Restrict registration to specific email domains.
```typescript
const emailValidation = useEmailValidation({
validateFn: async (email, context) => {
const domain = email.split('@')[1]?.toLowerCase();
const allowedDomains = context?.allowedDomains?.split(',') || [];
if (!allowedDomains.includes(domain)) {
return {
valid: false,
message: `Only ${allowedDomains.join(', ')} email addresses are allowed`
};
}
return { valid: true };
},
context: {
allowedDomains: 'company.com,partner.com,contractor.com'
}
});
```
### 5. Disposable Email Detection
Block temporary/disposable email addresses.
```typescript
const emailValidation = useEmailValidation({
validateFn: async (email) => {
const domain = email.split('@')[1];
const response = await fetch(
`/api/auth/check-disposable-email?domain=${encodeURIComponent(domain)}`
);
const data = await response.json();
return {
valid: !data.isDisposable,
message: data.isDisposable
? 'Disposable email addresses are not allowed. Please use a permanent email address.'
: undefined
};
}
});
```
### 6. MX Record Validation
Verify that the email domain has valid MX records.
```typescript
const emailValidation = useEmailValidation({
debounceMs: 1000, // Longer debounce for server checks
validateFn: async (email) => {
const domain = email.split('@')[1];
const response = await fetch(
`/api/auth/check-email-deliverable?domain=${encodeURIComponent(domain)}`
);
const data = await response.json();
return {
valid: data.hasValidMX,
message: data.hasValidMX
? undefined
: 'This email domain does not appear to be valid. Please check your email address.'
};
}
});
```
## Performance Optimization
### Debouncing Strategy
```typescript
// Default debounce times by use case:
const DEBOUNCE_TIMES = {
FORMAT_ONLY: 300, // Just format validation
BASIC_API: 500, // Simple API check (default)
DATABASE_QUERY: 800, // Database lookups
EXTERNAL_API: 1000, // Third-party services (MX, disposable)
};
// Usage:
const emailValidation = useEmailValidation({
debounceMs: DEBOUNCE_TIMES.EXTERNAL_API,
validateFn: checkWithExternalService
});
```
### Abort Controllers
The hook automatically cancels in-flight requests when:
- New validation is triggered
- Component unmounts
- Email value changes rapidly
```typescript
// Internal implementation handles cleanup:
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
```
## Error Handling
### Client-Side Errors
```typescript
const emailValidation = useEmailValidation({
validateFn: async (email) => {
try {
const response = await fetch(`/api/validate-email?email=${email}`);
if (!response.ok) {
throw new Error('Validation service unavailable');
}
const data = await response.json();
return { valid: data.valid, message: data.message };
} catch (error) {
console.error('Email validation error:', error);
return {
valid: false,
message: 'Unable to verify email at this time. Please try again.'
};
}
}
});
```
### Server-Side Errors
```typescript
export async function GET(request: NextRequest) {
try {
// Validation logic
} catch (error) {
console.error('Email validation error:', error);
return NextResponse.json(
{
valid: false,
message: 'Validation service error'
},
{ status: 500 }
);
}
}
```
## Testing
### Unit Tests for Format Validation
```typescript
describe('useEmailValidation - format validation', () => {
it('should validate correct email format', async () => {
const { result } = renderHook(() =>
useEmailValidation({
validateFn: async () => ({ valid: true })
})
);
await act(async () => {
await result.current.validate('user@example.com');
});
expect(result.current.isValid).toBe(true);
});
it('should reject invalid email format', async () => {
const { result } = renderHook(() =>
useEmailValidation({
validateFn: async () => ({ valid: true })
})
);
await act(async () => {
await result.current.validate('invalid-email');
});
expect(result.current.isValid).toBe(false);
expect(result.current.error).toContain('valid email address');
});
});
```
### Integration Tests
```typescript
describe('Email validation with invitation', () => {
it('should validate matching invitation email', async () => {
// Setup invitation with email
const invitation = await createTestInvitation({
email: 'invited@example.com'
});
// Test validation
const result = await validateInvitationEmail(
'invited@example.com',
invitation.token
);
expect(result.matches).toBe(true);
});
it('should reject non-matching email', async () => {
const invitation = await createTestInvitation({
email: 'invited@example.com'
});
const result = await validateInvitationEmail(
'other@example.com',
invitation.token
);
expect(result.matches).toBe(false);
});
});
```
## Accessibility
### ARIA Attributes
```tsx
<InputGroupInput type="email" aria-label="Email address"
aria-describedby="email-description email-error" aria-invalid={emailValidation.isValid===false}
aria-busy={emailValidation.isValidating} {...field} />
{emailValidation.error && (
<div id="email-error" role="alert">
{emailValidation.error}
</div>
)}
```
### Screen Reader Feedback
```tsx
<span className="sr-only" aria-live="polite">
{emailValidation.isValidating && 'Validating email...'}
{emailValidation.isValid === true && 'Email is valid'}
{emailValidation.isValid === false && emailValidation.error}
</span>
```
## Best Practices
### 1. Choose Appropriate Debounce Time
```typescript
// ❌ Too short - too many API calls
debounceMs: 100
// ✅ Good for basic checks
debounceMs: 500
// ✅ Good for external services
debounceMs: 1000
```
### 2. Skip Validation for Unchanged Emails
```typescript
const emailValidation = useEmailValidation({
currentEmail: user.email, // ✅ Skip if unchanged
validateFn: checkEmailAvailable
});
```
### 3. Provide Clear Error Messages
```typescript
// ❌ Generic message
return { valid: false, message: 'Invalid email' };
// ✅ Specific and helpful
return {
valid: false,
message: 'This email does not match the invitation. Please use the email address the invitation was sent
to.'
};
```
### 4. Handle Network Errors Gracefully
```typescript
validateFn: async (email) => {
try {
const response = await fetch(`/api/validate?email=${email}`);
const data = await response.json();
return { valid: data.valid, message: data.message };
} catch (error) {
// ✅ Fail gracefully with helpful message
return {
valid: false,
message: 'Unable to verify email. Please check your connection and try again.'
};
}
}
```
### 5. Use Context for Additional Parameters
```typescript
// ❌ Hardcoded or closure variables
const validateFn = async (email) => {
return checkEmail(email, inviteToken);
};
// ✅ Use context parameter
const emailValidation = useEmailValidation({
validateFn: async (email, context) => {
return checkEmail(email, context?.inviteToken);
},
context: { inviteToken }
});
```
## Comparison with Slug Validation
| Feature | Email Validation | Slug Validation |
|---------|------------------|-----------------|
| **Format Validation** | Built-in regex | External slugify function |
| **Debounce Default** | 500ms | 1000ms |
| **Visual Icon** | Mail | Link2 |
| **Validation Type** | Format + Custom | Uniqueness |
| **Context Support** | ✅ Yes | ❌ No |
| **Error Messages** | Custom per validation | Generic + custom |
| **Use Cases** | Invitations, uniqueness, domain rules | URL generation, uniqueness |
## Migration Guide
### From Local State to useEmailValidation
**Before**:
```typescript
const [isValidating, setIsValidating] = useState(false);
const [isValid, setIsValid] = useState<boolean | null>(null);
useEffect(() => {
const validate = async () => {
if (!email) return;
setIsValidating(true);
const result = await checkEmail(email);
setIsValid(result);
setIsValidating(false);
};
const timer = setTimeout(validate, 500);
return () => clearTimeout(timer);
}, [email]);
```
**After**:
```typescript
const emailValidation = useEmailValidation({
validateFn: async (email) => {
const result = await checkEmail(email);
return { valid: result };
}
});
useEffect(() => {
if (email) {
emailValidation.validate(email);
}
}, [email]);
```
## Related Documentation
- [Slug Validation System](./slug-validation-system.mdoc)
- [Form Validation Patterns](./form-validation-patterns.mdoc)
- [InputGroup Component](./input-group-component.mdoc)
- [Invitation System](./invitation-system.mdoc)
## Support
For questions or issues:
- Check the [FAQ](./faq.mdoc)
- Review [example implementations](./examples/email-validation.mdoc)
- Submit an issue on GitHub
````