zerobounce.js
1 /** 2 * ZeroBounce Email Validation API Client 3 * 4 * Validates email deliverability before sending to protect sender reputation. 5 * Results are cached in the email_validations table (90-day TTL by default). 6 * 7 * Vendor limits: ~100 req/sec. Defaults are conservative. 8 * Cost: ~$0.008/email (pay-as-you-go credits). 9 * 10 * Status meanings: 11 * valid → safe to send 12 * invalid → hard bounce guaranteed — BLOCKED 13 * catch-all → domain accepts all mail, mailbox existence unknown — allowed (warn) 14 * unknown → cannot determine (greylisting, anti-verification SMTP) — allowed 15 * spamtrap → honeypot address — BLOCKED (severe reputation risk) 16 * abuse → known complaint filer — BLOCKED 17 * do_not_mail → role/disposable/toxic — BLOCKED 18 * 19 * API Reference: https://www.zerobounce.net/docs/email-validation-api-quickstart/ 20 */ 21 22 import Logger from './logger.js'; 23 import { zeroBounceLimiter } from './rate-limiter.js'; 24 import { zeroBounceBreaker } from './circuit-breaker.js'; 25 import { run, getOne } from './db.js'; 26 import './load-env.js'; 27 28 const logger = new Logger('ZeroBounce'); 29 30 const ZEROBOUNCE_BASE_URL = 'https://api.zerobounce.net/v2'; 31 const CACHE_TTL_DAYS = parseInt(process.env.ZEROBOUNCE_CACHE_TTL_DAYS || '90', 10); 32 33 /** 34 * Statuses that must be blocked before sending. 35 * catch-all and unknown use fail-open policy (see module docstring). 36 */ 37 export const BLOCKED_STATUSES = new Set(['invalid', 'spamtrap', 'abuse', 'do_not_mail']); 38 39 /** 40 * Role-based email prefixes with historically low bounce rates (0-14%) that 41 * are safe to send to. High-bounce prefixes (info@ 24%, support@ 78%, 42 * contact@ 40%) remain blocked. Based on our own delivery data. 43 */ 44 const ALLOWED_ROLE_PREFIXES = new Set([ 45 'admin', 'office', 'service', 'hello', 'team', 'reception', 46 'enquiries', 'enquiry', 'quote', 'quotes', 'bookings', 'booking', 47 ]); 48 49 /** 50 * Determine if a do_not_mail result should actually be allowed. 51 * Only role_based sub_status with a low-bounce prefix is allowed; 52 * disposable, toxic, and global_suppression remain blocked. 53 */ 54 export function isAllowedRoleBased(email, subStatus) { 55 if (subStatus !== 'role_based' && subStatus !== 'role_based_catch_all') return false; 56 const prefix = email.toLowerCase().trim().split('@')[0]; 57 return ALLOWED_ROLE_PREFIXES.has(prefix); 58 } 59 60 /** 61 * Check if a validation result should be blocked. 62 */ 63 export function isBlocked(status, subStatus, email) { 64 if (!BLOCKED_STATUSES.has(status)) return false; 65 if (status === 'do_not_mail' && isAllowedRoleBased(email, subStatus)) return false; 66 return true; 67 } 68 69 // ─── Cache helpers ──────────────────────────────────────────────────────────── 70 71 /** 72 * Look up a cached validation result. Returns null on cache miss or expiry. 73 * @param {string} email 74 * @returns {Promise<{ status: string, sub_status: string|null } | null>} 75 */ 76 async function getCachedValidation(email) { 77 return await getOne( 78 `SELECT status, sub_status FROM email_validations 79 WHERE email = $1 AND expires_at > NOW()`, 80 [email.toLowerCase().trim()] 81 ); 82 } 83 84 /** 85 * Persist a validation result to the cache. 86 * ON CONFLICT handles re-validation of already-cached addresses. 87 * @param {string} email 88 * @param {{ status: string, sub_status?: string, free_email?: boolean, mx_found?: boolean }} result 89 * @returns {Promise<void>} 90 */ 91 async function setCachedValidation(email, result) { 92 const freeEmail = 93 result.free_email !== null && result.free_email !== undefined ? result.free_email : null; 94 const mxFound = 95 result.mx_found !== null && result.mx_found !== undefined ? result.mx_found : null; 96 97 await run( 98 `INSERT INTO email_validations 99 (email, status, sub_status, free_email, mx_found, validated_at, expires_at) 100 VALUES ($1, $2, $3, $4, $5, NOW(), NOW() + INTERVAL '${CACHE_TTL_DAYS} days') 101 ON CONFLICT (email) DO UPDATE SET 102 status = EXCLUDED.status, 103 sub_status = EXCLUDED.sub_status, 104 free_email = EXCLUDED.free_email, 105 mx_found = EXCLUDED.mx_found, 106 validated_at = EXCLUDED.validated_at, 107 expires_at = EXCLUDED.expires_at`, 108 [email.toLowerCase().trim(), result.status, result.sub_status ?? null, freeEmail, mxFound] 109 ); 110 } 111 112 // ─── API calls ──────────────────────────────────────────────────────────────── 113 114 /** 115 * Validate a single email address via ZeroBounce API. 116 * @param {string} email 117 * @returns {Promise<{ status: string, sub_status: string|null, free_email: boolean|null, mx_found: boolean|null }>} 118 */ 119 export async function validateEmailWithApi(email) { 120 const apiKey = process.env.ZEROBOUNCE_API_KEY; 121 if (!apiKey) throw new Error('ZEROBOUNCE_API_KEY is not configured'); 122 123 const url = new URL(`${ZEROBOUNCE_BASE_URL}/validate`); 124 url.searchParams.set('api_key', apiKey); 125 url.searchParams.set('email', email); 126 url.searchParams.set('ip_address', ''); 127 128 // Outer hard timeout (15s) — completely outside Bottleneck and opossum wrappers. 129 // AbortSignal.timeout(10s) alone is unreliable when nested inside limiter/breaker chains. 130 const ZB_HARD_TIMEOUT_MS = 15000; 131 let hardTimeoutId; 132 const hardTimeoutPromise = new Promise((_, reject) => { 133 hardTimeoutId = setTimeout( 134 () => reject(new Error(`ZeroBounce timed out after ${ZB_HARD_TIMEOUT_MS}ms for ${email}`)), 135 ZB_HARD_TIMEOUT_MS 136 ); 137 }); 138 139 const fetchPromise = zeroBounceLimiter.schedule(() => 140 zeroBounceBreaker.fire(() => 141 fetch(url.toString(), { 142 signal: AbortSignal.timeout(10000), 143 headers: { 'User-Agent': '333Method/1.0' }, 144 }) 145 ) 146 ); 147 148 let response; 149 try { 150 response = await Promise.race([fetchPromise, hardTimeoutPromise]); 151 } finally { 152 clearTimeout(hardTimeoutId); 153 } 154 155 if (!response.ok) { 156 const text = await response.text().catch(() => ''); 157 throw new Error(`ZeroBounce API error ${response.status}: ${text.substring(0, 200)}`); 158 } 159 160 const data = await response.json(); 161 162 if (data.error) { 163 throw new Error(`ZeroBounce: ${data.error}`); 164 } 165 166 return { 167 status: data.status, 168 sub_status: data.sub_status || null, 169 // API returns booleans or strings "true"/"false" — normalise to boolean|null 170 free_email: 171 data.free_email !== null && data.free_email !== undefined 172 ? String(data.free_email) === 'true' 173 : null, 174 mx_found: 175 data.mx_found !== null && data.mx_found !== undefined 176 ? String(data.mx_found) === 'true' 177 : null, 178 }; 179 } 180 181 /** 182 * Validate up to 200 emails in one batch API call. 183 * @param {string[]} emails - Max 200 addresses 184 * @returns {Promise<Map<string, { status: string, sub_status: string|null, free_email: boolean|null, mx_found: boolean|null }>>} 185 */ 186 export async function validateEmailBatchWithApi(emails) { 187 const apiKey = process.env.ZEROBOUNCE_API_KEY; 188 if (!apiKey) throw new Error('ZEROBOUNCE_API_KEY is not configured'); 189 190 if (emails.length === 0) return new Map(); 191 if (emails.length > 200) throw new Error('Batch size must not exceed 200 emails'); 192 193 const payload = { 194 api_key: apiKey, 195 email_batch: emails.map(e => ({ email_address: e, ip_address: '' })), 196 }; 197 198 const response = await zeroBounceLimiter.schedule(() => 199 zeroBounceBreaker.fire(() => 200 fetch(`${ZEROBOUNCE_BASE_URL}/validatebatch`, { 201 method: 'POST', 202 headers: { 'Content-Type': 'application/json', 'User-Agent': '333Method/1.0' }, 203 body: JSON.stringify(payload), 204 signal: AbortSignal.timeout(30000), 205 }) 206 ) 207 ); 208 209 if (!response.ok) { 210 const text = await response.text().catch(() => ''); 211 throw new Error(`ZeroBounce batch API error ${response.status}: ${text.substring(0, 200)}`); 212 } 213 214 const data = await response.json(); 215 216 const resultMap = new Map(); 217 for (const item of data.email_batch ?? []) { 218 if (!item.address) continue; 219 resultMap.set(item.address.toLowerCase(), { 220 status: item.status, 221 sub_status: item.sub_status || null, 222 // API returns booleans or strings "true"/"false" — normalise to boolean|null 223 free_email: 224 item.free_email !== null && item.free_email !== undefined 225 ? String(item.free_email) === 'true' 226 : null, 227 mx_found: 228 item.mx_found !== null && item.mx_found !== undefined 229 ? String(item.mx_found) === 'true' 230 : null, 231 }); 232 } 233 return resultMap; 234 } 235 236 /** 237 * Check remaining ZeroBounce credits. 238 * @returns {Promise<number>} Remaining credits (-1 = no credits) 239 */ 240 export async function checkCredits() { 241 const apiKey = process.env.ZEROBOUNCE_API_KEY; 242 if (!apiKey) throw new Error('ZEROBOUNCE_API_KEY is not configured'); 243 244 const response = await fetch(`${ZEROBOUNCE_BASE_URL}/getcredits?api_key=${apiKey}`, { 245 signal: AbortSignal.timeout(5000), 246 }); 247 248 if (!response.ok) { 249 throw new Error(`ZeroBounce credits check failed: HTTP ${response.status}`); 250 } 251 252 const data = await response.json(); 253 const credits = parseFloat(data.Credits ?? data.credits ?? '0'); 254 return isNaN(credits) ? 0 : credits; 255 } 256 257 // ─── Public interface (with cache) ─────────────────────────────────────────── 258 259 /** 260 * Validate an email address, checking the DB cache first. 261 * Fails open if ZeroBounce is disabled, unconfigured, or returns an error — 262 * the Resend bounce webhook pipeline is the safety net for unknowns. 263 * 264 * @param {string} email 265 * @param {{ useCache?: boolean }} [options] 266 * @returns {Promise<{ 267 * status: string, 268 * sub_status: string|null, 269 * blocked: boolean, 270 * cached: boolean, 271 * error: string|null 272 * }>} 273 */ 274 export async function validateEmail(email, { useCache = true } = {}) { 275 const enabled = (process.env.ZEROBOUNCE_ENABLED ?? 'true') !== 'false'; 276 const apiKey = process.env.ZEROBOUNCE_API_KEY; 277 278 if (!enabled || !apiKey) { 279 return { status: 'skipped', sub_status: null, blocked: false, cached: false, error: null }; 280 } 281 282 try { 283 if (useCache) { 284 const cached = await getCachedValidation(email); 285 if (cached) { 286 return { 287 status: cached.status, 288 sub_status: cached.sub_status, 289 blocked: isBlocked(cached.status, cached.sub_status, email), 290 cached: true, 291 error: null, 292 }; 293 } 294 } 295 296 const result = await validateEmailWithApi(email); 297 await setCachedValidation(email, result); 298 299 return { 300 status: result.status, 301 sub_status: result.sub_status, 302 blocked: isBlocked(result.status, result.sub_status, email), 303 cached: false, 304 error: null, 305 }; 306 } catch (error) { 307 // Fail open: ZeroBounce down or credits exhausted → allow send with warning 308 logger.warn(`ZeroBounce validation failed for ${email}, failing open: ${error.message}`); 309 return { 310 status: 'unknown', 311 sub_status: null, 312 blocked: false, 313 cached: false, 314 error: error.message, 315 }; 316 } 317 } 318 319 export default { 320 validateEmail, 321 validateEmailWithApi, 322 validateEmailBatchWithApi, 323 checkCredits, 324 BLOCKED_STATUSES, 325 isBlocked, 326 isAllowedRoleBased, 327 zeroBounceLimiter, 328 };