feat: Add email verification endpoint at /v1/verify

This commit is contained in:
Dries Augustyns
2025-12-24 09:54:50 +01:00
parent 8f725c7c84
commit 6a9f6aa65a
7 changed files with 375 additions and 1 deletions
+4
View File
@@ -22,6 +22,7 @@
"@plunk/shared": "*",
"@plunk/types": "*",
"@react-email/render": "^2.0.0",
"@zootools/email-spell-checker": "^1.12.0",
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
"bullmq": "^5.63.2",
@@ -29,11 +30,13 @@
"cors": "^2.8.5",
"csv-parse": "^6.1.0",
"dayjs": "^1.11.19",
"disposable-email": "^0.2.3",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"helmet": "^8.1.0",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"mailchecker": "^6.0.19",
"morgan": "^1.10.0",
"multer": "^2.0.2",
"signale": "^1.4.0",
@@ -43,6 +46,7 @@
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/disposable-email": "^0",
"@types/express": "^5.0.5",
"@types/helmet": "^4.0.0",
"@types/jsonwebtoken": "^9.0.6",
+56 -1
View File
@@ -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;
}
}
@@ -5,6 +5,7 @@
"---Public API---",
"public-api/sendEmail",
"public-api/trackEvent",
"public-api/verifyEmail",
"---Resources---",
"contacts",
"templates",
+183
View File
@@ -592,6 +592,189 @@
}
}
},
"/v1/verify": {
"post": {
"tags": ["Public API"],
"summary": "Verify email address",
"description": "Verify an email address for validity, check if it's from a disposable domain, verify MX records, and detect potential typos with suggestions.",
"operationId": "verifyEmail",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["email"],
"properties": {
"email": {
"type": "string",
"format": "email",
"description": "Email address to verify"
}
}
},
"examples": {
"validEmail": {
"summary": "Valid email address",
"value": {
"email": "user@gmail.com"
}
},
"typoEmail": {
"summary": "Email with potential typo",
"value": {
"email": "user@gmial.com"
}
},
"disposableEmail": {
"summary": "Disposable email address",
"value": {
"email": "user@tempmail.com"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Email verification completed successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"description": "Always true for successful requests"
},
"data": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"description": "Email address that was verified"
},
"valid": {
"type": "boolean",
"description": "Whether the email appears to be valid overall"
},
"isDisposable": {
"type": "boolean",
"description": "Whether the email is from a disposable/temporary email domain"
},
"isTypo": {
"type": "boolean",
"description": "Whether a potential typo was detected in the email address"
},
"domainExists": {
"type": "boolean",
"description": "Whether the domain exists (has DNS A or AAAA records)"
},
"hasMxRecords": {
"type": "boolean",
"description": "Whether the domain has MX records configured for email delivery"
},
"suggestedEmail": {
"type": "string",
"format": "email",
"description": "Suggested correction if a typo was detected (optional)",
"nullable": true
},
"reasons": {
"type": "array",
"items": {
"type": "string"
},
"description": "Array of human-readable reasons describing the verification results"
}
},
"required": ["email", "valid", "isDisposable", "isTypo", "domainExists", "hasMxRecords", "reasons"]
}
}
},
"examples": {
"validEmail": {
"summary": "Valid email",
"value": {
"success": true,
"data": {
"email": "user@gmail.com",
"valid": true,
"isDisposable": false,
"isTypo": false,
"domainExists": true,
"hasMxRecords": true,
"reasons": [
"Email appears to be valid"
]
}
}
},
"typoDetected": {
"summary": "Email with typo detected",
"value": {
"success": true,
"data": {
"email": "user@gmial.com",
"valid": false,
"isDisposable": false,
"isTypo": true,
"domainExists": false,
"hasMxRecords": false,
"suggestedEmail": "user@gmail.com",
"reasons": [
"Possible typo detected, did you mean gmail.com?",
"Domain does not exist"
]
}
}
},
"disposableEmail": {
"summary": "Disposable email detected",
"value": {
"success": true,
"data": {
"email": "user@tempmail.com",
"valid": true,
"isDisposable": true,
"isTypo": false,
"domainExists": true,
"hasMxRecords": true,
"reasons": [
"Email appears to be valid"
]
}
}
}
}
}
}
},
"400": {
"description": "Bad request - invalid email format",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized - invalid or missing API key",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/contacts": {
"get": {
"tags": ["Contacts"],