/ src / utils / zerobounce.js
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  };