190 lines
6.0 KiB
TypeScript
190 lines
6.0 KiB
TypeScript
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<string> | 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<EmailVerificationResult> {
|
|
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<Set<string>> {
|
|
// 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<string>();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a domain is disposable
|
|
*/
|
|
private static async isDisposableDomain(domain: string): Promise<boolean> {
|
|
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());
|
|
}
|
|
}
|