import {promises as dns} from 'dns'; import {run} from '@zootools/email-spell-checker'; import type {EmailVerificationResult} from '@plunk/types'; import {redis} from '../database/redis.js'; const DISPOSABLE_DOMAINS_URL = 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf'; const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains'; const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily) // Known email forwarding/alias services const FORWARDING_DOMAINS = new Set([ 'privaterelay.appleid.com', // Apple Sign In 'mozmail.com', // Firefox Relay 'simplelogin.com', // SimpleLogin 'simplelogin.fr', 'simplelogin.co', 'simplelogin.io', 'aleeas.com', 'slmail.me', 'dralias.com', '8shield.net', 'anonaddy.com', // Addy.io 'anonaddy.me', 'addy.io', 'duck.com', // DuckDuckGo '33mail.com', // 33mail '33m.co', 'passmail.com', // Proton Pass 'passmail.net', 'passinbox.com', 'passfwd.com', 'y.yo.fr', 'opayq.com', // IronVest (formerly Blur) 'cloak.id', // Cloaked 'erine.email', // Erine 'use.startmail.com', // StartMail ]); export class EmailVerificationService { private static disposableDomainsSet: Set | null = null; /** * Verify an email address * - Checks for MX records (required for receiving email) * - Checks if domain exists (DNS A/AAAA records) - informational only * - Detects disposable email addresses * - Detects forwarding/alias email addresses * - Suggests corrections for common typos */ static async verifyEmail(email: string): Promise { const result: EmailVerificationResult = { email, valid: true, isDisposable: false, isAlias: false, isTypo: false, isPlusAddressed: 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 using GitHub list result.isDisposable = await this.isDisposableDomain(domain); // Check if email is from a known forwarding/alias service result.isAlias = this.isForwardingDomain(domain); // Check for plus addressing result.isPlusAddressed = emailParts[0]!.includes('+'); // 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 MX records first - this is what matters for email delivery // A domain can receive email with only MX records, no A/AAAA records needed 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'); } // Check if domain exists (has A/AAAA records) - informational only // This doesn't affect validity since email delivery only requires MX 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 { // Domain doesn't have A/AAAA records, but this is OK if it has MX records result.domainExists = false; } } // 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; } /** * Fetch and cache the disposable domains list from GitHub * Uses Redis for caching with 24-hour TTL * Falls back to in-memory cache if Redis fails */ private static async getDisposableDomains(): Promise> { // Return in-memory cache if available if (this.disposableDomainsSet) { return this.disposableDomainsSet; } try { // Try to get from Redis cache first const cached = await redis.get(DISPOSABLE_DOMAINS_CACHE_KEY); if (cached) { const domains = JSON.parse(cached) as string[]; this.disposableDomainsSet = new Set(domains); return this.disposableDomainsSet; } // Fetch from GitHub if not in cache const response = await fetch(DISPOSABLE_DOMAINS_URL); if (!response.ok) { throw new Error(`Failed to fetch disposable domains: ${response.statusText}`); } const text = await response.text(); const domains = text .split('\n') .map(line => line.trim()) .filter(line => line && !line.startsWith('#')); // Filter empty lines and comments // Cache in Redis await redis.set(DISPOSABLE_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS); // Cache in memory this.disposableDomainsSet = new Set(domains); return this.disposableDomainsSet; } catch (error) { console.error('Error fetching disposable domains:', error); // Return empty set as fallback - don't block email verification return new Set(); } } /** * Check if a domain is disposable */ private static async isDisposableDomain(domain: string): Promise { const disposableDomains = await this.getDisposableDomains(); return disposableDomains.has(domain.toLowerCase()); } /** * Check if a domain is a known forwarding/alias service */ private static isForwardingDomain(domain: string): boolean { return FORWARDING_DOMAINS.has(domain.toLowerCase()); } }