contact-validator.js
1 /** 2 * Contact Validation Utilities 3 * Validates phone numbers, email addresses, and social URLs 4 */ 5 6 import { promises as dns } from 'dns'; 7 import pkg from 'google-libphonenumber'; 8 9 const { PhoneNumberUtil, PhoneNumberType } = pkg; 10 const phoneUtil = PhoneNumberUtil.getInstance(); 11 12 /** 13 * Validate a phone number for a given country using google-libphonenumber 14 * @param {string} number - Phone number (any format) 15 * @param {string} countryCode - ISO 3166-1 alpha-2 country code (e.g. 'AU', 'US') 16 * @returns {{ valid: boolean, reason: string|null }} 17 */ 18 // Map non-standard country codes to ISO 3166-1 alpha-2 as used by libphonenumber 19 const COUNTRY_CODE_ALIASES = { UK: 'GB', ENG: 'GB', SCO: 'GB', WAL: 'GB' }; 20 21 export function validatePhone(number, countryCode) { 22 if (!number || typeof number !== 'string') { 23 return { valid: false, reason: 'missing phone number' }; 24 } 25 26 const cleaned = number.trim(); 27 if (!cleaned) return { valid: false, reason: 'empty phone number' }; 28 29 // Reject obvious placeholder/test numbers: 6+ consecutive identical digits 30 // e.g. +15555555555 (all-5s), +10000000000, etc. 31 const digitsOnly = cleaned.replace(/\D/g, ''); 32 if (/(\d)\1{5,}/.test(digitsOnly)) { 33 return { valid: false, reason: `${cleaned} appears to be a placeholder (repeated digits)` }; 34 } 35 36 // Reject NANP 555 subscriber exchange numbers (+1 AAAA 555-XXXX) 37 // These are reserved/fictional in North America (US/CA/etc.) 38 // 11 digits: 1 + 3-digit area code + 555 + 4 digits 39 if (/^1\d{3}555\d{4}$/.test(digitsOnly)) { 40 return { valid: false, reason: `${cleaned} is a fictitious NANP 555 number` }; 41 } 42 43 // Normalize country code aliases (e.g. UK → GB) 44 45 const normalizedCC = 46 (countryCode && COUNTRY_CODE_ALIASES[countryCode]) || countryCode || undefined; 47 48 try { 49 const parsed = phoneUtil.parse(cleaned, normalizedCC); 50 if (!phoneUtil.isValidNumber(parsed)) { 51 return { valid: false, reason: `${cleaned} is not a valid phone number` }; 52 } 53 if (normalizedCC && !phoneUtil.isValidNumberForRegion(parsed, normalizedCC)) { 54 return { 55 valid: false, 56 reason: `${cleaned} is not a valid ${countryCode} phone number`, 57 }; 58 } 59 return { valid: true, reason: null }; 60 } catch (err) { 61 return { valid: false, reason: `could not parse phone number: ${err.message}` }; 62 } 63 } 64 65 /** 66 * Validate a phone is mobile (can receive SMS) 67 * @param {string} number - Phone number in E.164 or local format 68 * @param {string} countryCode - ISO country code 69 * @returns {boolean} 70 */ 71 export function isMobilePhone(number, countryCode) { 72 const normalizedCC = 73 (countryCode && COUNTRY_CODE_ALIASES[countryCode]) || countryCode || undefined; 74 try { 75 const parsed = phoneUtil.parse(number, normalizedCC); 76 const type = phoneUtil.getNumberType(parsed); 77 return type === PhoneNumberType.MOBILE || type === PhoneNumberType.FIXED_LINE_OR_MOBILE; 78 } catch { 79 return false; 80 } 81 } 82 83 /** 84 * Validate an email address format and optionally check domain MX records 85 * DNS MX lookup is faster and more accurate than HTTP GET for email domains 86 * @param {string} email - Email address 87 * @param {Object} options 88 * @param {boolean} options.checkMx - Whether to do DNS MX lookup (default: true) 89 * @returns {Promise<{ valid: boolean, reason: string|null }>} 90 */ 91 export async function validateEmail(email, { checkMx = true } = {}) { 92 if (!email || typeof email !== 'string') { 93 return { valid: false, reason: 'missing email' }; 94 } 95 96 const cleaned = email.trim().toLowerCase(); 97 98 // Basic format check first 99 if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(cleaned)) { 100 return { valid: false, reason: `"${email}" is not a valid email format` }; 101 } 102 103 if (!checkMx) return { valid: true, reason: null }; 104 105 const domain = cleaned.split('@')[1]; 106 107 try { 108 const mx = await dns.resolveMx(domain); 109 if (!mx || mx.length === 0) { 110 return { valid: false, reason: `domain ${domain} has no MX records (cannot receive email)` }; 111 } 112 return { valid: true, reason: null }; 113 } catch { 114 return { valid: false, reason: `domain ${domain} not found or has no MX records` }; 115 } 116 } 117 118 /** 119 * Validate a social profile URL by doing an HTTP HEAD request 120 * @param {string} url - Social profile URL 121 * @returns {Promise<{ valid: boolean, status: number|null, reason: string|null }>} 122 */ 123 export async function validateSocialUrl(url) { 124 if (!url || typeof url !== 'string') { 125 return { valid: false, status: null, reason: 'missing URL' }; 126 } 127 128 try { 129 const res = await fetch(url, { 130 method: 'HEAD', 131 redirect: 'follow', 132 signal: AbortSignal.timeout(8000), 133 headers: { 'User-Agent': 'Mozilla/5.0 (compatible; contact-validator/1.0)' }, 134 }); 135 136 const ok = res.status < 400; 137 return { 138 valid: ok, 139 status: res.status, 140 reason: ok ? null : `HTTP ${res.status} for ${url}`, 141 }; 142 } catch (err) { 143 return { valid: false, status: null, reason: `request failed: ${err.message}` }; 144 } 145 } 146 147 export default { validatePhone, isMobilePhone, validateEmail, validateSocialUrl };