feat: Add email verification endpoint at /v1/verify
This commit is contained in:
@@ -8,6 +8,7 @@ import {prisma} from '../database/prisma.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {EmailService} from '../services/EmailService.js';
|
||||
import {EmailVerificationService} from '../services/EmailVerificationService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {NotFound, ValidationError} from '../exceptions/index.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -15,7 +16,7 @@ import {DASHBOARD_URI} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
* Public API Actions Controller
|
||||
* Handles track event and transactional email endpoints
|
||||
* Handles track event, transactional email, and email verification endpoints
|
||||
*/
|
||||
@Controller('v1')
|
||||
export class Actions {
|
||||
@@ -341,4 +342,58 @@ export class Actions {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/verify
|
||||
* Verify an email address
|
||||
*
|
||||
* Request body:
|
||||
* - email: string (required) - Email address to verify
|
||||
*
|
||||
* Response:
|
||||
* - success: boolean
|
||||
* - data: object with verification results
|
||||
* - email: string - Email address that was verified
|
||||
* - valid: boolean - Whether the email appears to be valid
|
||||
* - isDisposable: boolean - Whether the email is from a disposable domain
|
||||
* - hasMxRecords: boolean - Whether the domain has MX records configured
|
||||
* - suggestedEmail?: string - Suggested correction if typo detected
|
||||
* - reasons: string[] - Array of reasons describing the verification results
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* email: "user@gmial.com"
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* email: "user@gmial.com",
|
||||
* valid: false,
|
||||
* isDisposable: false,
|
||||
* hasMxRecords: false,
|
||||
* suggestedEmail: "user@gmail.com",
|
||||
* reasons: [
|
||||
* "Possible typo detected, did you mean user@gmail.com?",
|
||||
* "Domain does not exist or has no MX records"
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
@Post('verify')
|
||||
@Middleware([requireSecretKey])
|
||||
@CatchAsync
|
||||
public async verify(req: Request, res: Response, _next: NextFunction) {
|
||||
// Zod validation - errors automatically handled by global error handler
|
||||
const {email} = ActionSchemas.verify.parse(req.body);
|
||||
|
||||
// Verify the email address
|
||||
const verificationResult = await EmailVerificationService.verifyEmail(email);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: verificationResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import {promises as dns} from 'dns';
|
||||
import {run} from '@zootools/email-spell-checker';
|
||||
import disposable from 'disposable-email';
|
||||
|
||||
export interface EmailVerificationResult {
|
||||
email: string;
|
||||
valid: boolean;
|
||||
isDisposable: boolean;
|
||||
isTypo: boolean;
|
||||
domainExists: boolean;
|
||||
hasMxRecords: boolean;
|
||||
suggestedEmail?: string;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export class EmailVerificationService {
|
||||
/**
|
||||
* Verify an email address
|
||||
* - Checks if domain exists (DNS A/AAAA records)
|
||||
* - Checks for MX records
|
||||
* - Detects disposable email addresses
|
||||
* - Suggests corrections for common typos
|
||||
*/
|
||||
static async verifyEmail(email: string): Promise<EmailVerificationResult> {
|
||||
const result: EmailVerificationResult = {
|
||||
email,
|
||||
valid: true,
|
||||
isDisposable: false,
|
||||
isTypo: false,
|
||||
domainExists: false,
|
||||
hasMxRecords: false,
|
||||
reasons: [],
|
||||
};
|
||||
|
||||
// Extract domain from email
|
||||
const emailParts = email.split('@');
|
||||
if (emailParts.length !== 2) {
|
||||
result.valid = false;
|
||||
result.reasons.push('Invalid email format');
|
||||
return result;
|
||||
}
|
||||
|
||||
const domain = emailParts[1]!; // Safe to assert, we already validated length
|
||||
|
||||
// Check if email is from a disposable domain
|
||||
// MailChecker.isValid returns false for disposable emails
|
||||
result.isDisposable = disposable.validate(domain);
|
||||
|
||||
// Check for common typos and suggest corrections
|
||||
const typoCheck = run({email});
|
||||
if (typoCheck && typoCheck.address && typoCheck.address !== email) {
|
||||
result.suggestedEmail = typoCheck.full;
|
||||
result.reasons.push(`Possible typo detected, did you mean ${typoCheck.domain}?`);
|
||||
result.isTypo = true;
|
||||
}
|
||||
|
||||
// Check if domain exists (has any DNS records)
|
||||
try {
|
||||
await dns.resolve(domain, 'A');
|
||||
result.domainExists = true;
|
||||
} catch {
|
||||
// Try AAAA records if A records fail
|
||||
try {
|
||||
await dns.resolve(domain, 'AAAA');
|
||||
result.domainExists = true;
|
||||
} catch {
|
||||
result.domainExists = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
// Check MX records (only if domain exists)
|
||||
if (result.domainExists) {
|
||||
try {
|
||||
const mxRecords = await dns.resolveMx(domain);
|
||||
result.hasMxRecords = mxRecords && mxRecords.length > 0;
|
||||
if (!result.hasMxRecords) {
|
||||
result.valid = false;
|
||||
result.reasons.push('No MX records found for domain');
|
||||
}
|
||||
} catch {
|
||||
result.hasMxRecords = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('No MX records found for domain');
|
||||
}
|
||||
}
|
||||
|
||||
// If no issues were found, add a success reason
|
||||
if (result.valid && result.reasons.length === 0) {
|
||||
result.reasons.push('Email appears to be valid');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user