diff --git a/apps/api/package.json b/apps/api/package.json index 171ae75..00566a6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/controllers/Actions.ts b/apps/api/src/controllers/Actions.ts index 00314bb..199f5ee 100644 --- a/apps/api/src/controllers/Actions.ts +++ b/apps/api/src/controllers/Actions.ts @@ -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, + }); + } } diff --git a/apps/api/src/services/EmailVerificationService.ts b/apps/api/src/services/EmailVerificationService.ts new file mode 100644 index 0000000..13e280d --- /dev/null +++ b/apps/api/src/services/EmailVerificationService.ts @@ -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 { + 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; + } +} diff --git a/apps/wiki/content/docs/api-reference/meta.json b/apps/wiki/content/docs/api-reference/meta.json index cdf22d9..257060d 100644 --- a/apps/wiki/content/docs/api-reference/meta.json +++ b/apps/wiki/content/docs/api-reference/meta.json @@ -5,6 +5,7 @@ "---Public API---", "public-api/sendEmail", "public-api/trackEvent", + "public-api/verifyEmail", "---Resources---", "contacts", "templates", diff --git a/apps/wiki/openapi.json b/apps/wiki/openapi.json index fa73813..a48a2bf 100644 --- a/apps/wiki/openapi.json +++ b/apps/wiki/openapi.json @@ -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"], diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 117c020..2d5f715 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -390,6 +390,9 @@ export const ActionSchemas = { message: 'Total attachment size must not exceed 10MB', }, ), + verify: z.object({ + email, + }), } as const; export const BillingLimitSchemas = { diff --git a/yarn.lock b/yarn.lock index 356d56a..055bfde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6552,6 +6552,13 @@ __metadata: languageName: node linkType: hard +"@types/disposable-email@npm:^0": + version: 0.2.2 + resolution: "@types/disposable-email@npm:0.2.2" + checksum: 10c0/8e96fe0e5d9677629d848cae2245e88d182a712ee53179d83a7ce371da836585fb2a4fed29ead263c2aec74d100daeff5af9dc2a54dfbac3f20ffc3b875fb087 + languageName: node + linkType: hard + "@types/eslint-scope@npm:^3.7.7": version: 3.7.7 resolution: "@types/eslint-scope@npm:3.7.7" @@ -7606,6 +7613,13 @@ __metadata: languageName: node linkType: hard +"@zootools/email-spell-checker@npm:^1.12.0": + version: 1.12.0 + resolution: "@zootools/email-spell-checker@npm:1.12.0" + checksum: 10c0/0b2fa9d0aaf1e6d846fe9e261276fecbd5c48db2689814b702bcec6b63729d097b245f59cdfb3dfca65d66b68de24b67e3b317494bcfbfdd166c244cd298a0cc + languageName: node + linkType: hard + "abbrev@npm:^4.0.0": version: 4.0.0 resolution: "abbrev@npm:4.0.0" @@ -7829,12 +7843,14 @@ __metadata: "@types/bcrypt": "npm:^6.0.0" "@types/cookie-parser": "npm:^1.4.7" "@types/cors": "npm:^2.8.17" + "@types/disposable-email": "npm:^0" "@types/express": "npm:^5.0.5" "@types/helmet": "npm:^4.0.0" "@types/jsonwebtoken": "npm:^9.0.6" "@types/morgan": "npm:^1.9.9" "@types/multer": "npm:^2.0.0" "@types/signale": "npm:^1.4.7" + "@zootools/email-spell-checker": "npm:^1.12.0" bcrypt: "npm:^6.0.0" body-parser: "npm:^2.2.0" bullmq: "npm:^5.63.2" @@ -7843,11 +7859,13 @@ __metadata: cors: "npm:^2.8.5" csv-parse: "npm:^6.1.0" dayjs: "npm:^1.11.19" + disposable-email: "npm:^0.2.3" dotenv: "npm:^17.2.3" express: "npm:^5.1.0" helmet: "npm:^8.1.0" ioredis: "npm:^5.8.2" jsonwebtoken: "npm:^9.0.2" + mailchecker: "npm:^6.0.19" morgan: "npm:^1.10.0" multer: "npm:^2.0.2" signale: "npm:^1.4.0" @@ -9490,6 +9508,13 @@ __metadata: languageName: node linkType: hard +"disposable-email@npm:^0.2.3": + version: 0.2.3 + resolution: "disposable-email@npm:0.2.3" + checksum: 10c0/d2da52e225bb784e640f9aff6c3e319c596e82c778b5483e36ff67eba5c5b9df6f59ed8c26be401b0813c921783e0b271fb97d49b33a73cb46dd010dc7081931 + languageName: node + linkType: hard + "dlv@npm:^1.1.3": version: 1.1.3 resolution: "dlv@npm:1.1.3" @@ -13296,6 +13321,13 @@ __metadata: languageName: node linkType: hard +"mailchecker@npm:^6.0.19": + version: 6.0.19 + resolution: "mailchecker@npm:6.0.19" + checksum: 10c0/96269fc53d5103f1950926554ac92cb38ff6c8511c7628d13a36e74572f88484a47d8789d35902cb69589c1eeb7c8d96d46105f92d285c4dfabbb00b88e8b8a3 + languageName: node + linkType: hard + "mailparser@npm:^3.7.1": version: 3.9.1 resolution: "mailparser@npm:3.9.1"