Files
plunk/apps/api/src/services/EmailVerificationService.ts
T

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());
}
}